diff --git a/.eslintignore b/.eslintignore index 83eaa25a..de36b4b3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -18,3 +18,4 @@ test.js config/manifest.json dt-skill/dist/ +agent-market/ diff --git a/.gitignore b/.gitignore index 07519af5..75cefb87 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ run logs cache .history -public +/public resources agent-market app/view diff --git a/.prettierignore b/.prettierignore index 1f73ddd5..f4552460 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,4 @@ config/manifest.json # Build output and runtime data (already in .gitignore) dt-skill/dist/ run/ +agent-market/ diff --git a/app/controller/agents.js b/app/controller/agents.js index 983178b5..e8e7ea56 100644 --- a/app/controller/agents.js +++ b/app/controller/agents.js @@ -1,5 +1,4 @@ const Controller = require('egg').Controller; -const fs = require('fs'); class AgentsController extends Controller { async getAgentList() { @@ -35,40 +34,50 @@ class AgentsController extends Controller { this.ctx.body = this.app.utils.response(true, data); } - async importAgentFile() { - const files = this.ctx.request.files - ? Array.isArray(this.ctx.request.files) - ? this.ctx.request.files - : [this.ctx.request.files] - : []; - const file = files[0]; - - if (!file) { - this.ctx.throw(400, '缺少上传文件'); + async deleteAgent() { + const data = await this.ctx.service.agents.deleteAgent(this.ctx.request.body || {}); + this.ctx.body = this.app.utils.response(true, data); + } + // 从指定 GitLab 仓库导入 Agent + async importAgentFromGit() { + const { gitUrl, gitBranch, category } = this.ctx.request.body || {}; + if (!gitUrl) { + this.ctx.throw(400, '缺少 gitUrl 参数'); } + const data = await this.ctx.service.agents.importAgentFromGit( + gitUrl, + gitBranch || 'master', + category + ); + this.ctx.body = this.app.utils.response(true, data); + } - try { - const data = await this.ctx.service.agents.importAgentFile( - this.ctx.request.body || {}, - file - ); + // 同步 Git 仓库代码,支持按 name 单独同步或全量同步 + async syncGitAgents() { + const { name } = this.ctx.request.body || {}; + if (name) { + // 单独同步单个 Agent + const data = await this.ctx.service.agents.syncGitAgentByName(name); this.ctx.body = this.app.utils.response(true, data); - } finally { - // 清理本次请求上传的所有临时文件,防止多文件或异常时泄漏 - for (const item of files) { - if (item?.filepath && fs.existsSync(item.filepath)) { - try { - fs.unlinkSync(item.filepath); - } catch (error) { - this.ctx.logger.warn(`[agents] 清理上传文件失败: ${error.message}`); - } - } - } + return; } + const data = await this.ctx.service.agents.syncAllGitAgents(); + this.ctx.body = this.app.utils.response(true, data); } - async deleteAgent() { - const data = await this.ctx.service.agents.deleteAgent(this.ctx.request.body || {}); + // 更新 Agent 的 Git 仓库配置,支持可选立即同步 + async updateAgentGitConfig() { + const { name, gitUrl, gitBranch, category, syncNow } = this.ctx.request.body || {}; + if (!name) { + this.ctx.throw(400, '缺少 name 参数'); + } + const data = await this.ctx.service.agents.updateAgentGitConfig({ + name, + gitUrl, + gitBranch, + category, + syncNow: Boolean(syncNow), + }); this.ctx.body = this.app.utils.response(true, data); } } diff --git a/app/model/agent.js b/app/model/agent.js index d9ec1919..8703be85 100644 --- a/app/model/agent.js +++ b/app/model/agent.js @@ -66,8 +66,7 @@ module.exports = (app) => { }, logo_size: { type: INTEGER, - allowNull: false, - defaultValue: 0, + allowNull: true, comment: 'Logo 大小', }, logo_hash: { @@ -79,15 +78,24 @@ module.exports = (app) => { allowNull: false, comment: '内容哈希', }, - source_file_name: { - type: STRING(255), - comment: '上传文件名', + git_url: { + type: STRING(1000), + comment: 'GitLab 仓库地址', }, - file_count: { - type: INTEGER, - allowNull: false, - defaultValue: 0, - comment: '文件数量', + git_branch: { + type: STRING(100), + defaultValue: 'master', + comment: 'GitLab 仓库分支', + }, + last_git_refresh_at: { + type: DATE, + allowNull: true, + comment: '最近一次刷新/检查 Git 时间', + }, + last_git_sync_at: { + type: DATE, + allowNull: true, + comment: '最近一次代码变动同步时间', }, is_delete: { type: TINYINT, diff --git a/app/model/agent_file.js b/app/model/agent_file.js deleted file mode 100644 index daf51c7d..00000000 --- a/app/model/agent_file.js +++ /dev/null @@ -1,84 +0,0 @@ -module.exports = (app) => { - const { INTEGER, STRING, TEXT, DATE, TINYINT } = app.Sequelize; - - const AgentFile = app.model.define( - 'agent_file', - { - id: { - type: INTEGER, - primaryKey: true, - autoIncrement: true, - }, - agent_id: { - type: INTEGER, - allowNull: false, - comment: 'agents.id', - }, - file_path: { - type: STRING(512), - allowNull: false, - comment: 'Agent 内相对路径', - }, - mime_type: { - type: STRING(100), - comment: '文件 MIME', - }, - size: { - type: INTEGER, - allowNull: false, - defaultValue: 0, - comment: '文件大小', - }, - is_binary: { - type: TINYINT, - allowNull: false, - defaultValue: 0, - comment: '是否二进制', - }, - encoding: { - type: STRING(20), - allowNull: false, - defaultValue: 'utf8', - comment: '内容编码', - }, - mode: { - type: INTEGER, - allowNull: false, - defaultValue: 0, - comment: 'Unix 权限', - }, - content: { - type: TEXT('long'), - comment: '文件内容', - }, - is_delete: { - type: TINYINT, - allowNull: false, - defaultValue: 0, - }, - created_at: { - type: DATE, - allowNull: false, - defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'), - }, - updated_at: { - type: DATE, - allowNull: false, - defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'), - }, - }, - { - freezeTableName: true, - tableName: 'agent_files', - timestamps: true, - createdAt: 'created_at', - updatedAt: 'updated_at', - indexes: [ - { unique: true, fields: ['agent_id', 'file_path'] }, - { fields: ['agent_id'] }, - ], - } - ); - - return AgentFile; -}; diff --git a/app/public/create-plugin.sh b/app/public/create-plugin.sh new file mode 100644 index 00000000..398d9b4a --- /dev/null +++ b/app/public/create-plugin.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# +# create-plugin.sh — 快速创建同时面向 Claude Code 与 Codex 的跨端 plugin 骨架。 +# +# 用法: +# ./create-plugin.sh [目标路径] +# +# 示例: +# ./create-plugin.sh my-plugin # 在 ./ 下建 my-plugin/ +# ./create-plugin.sh my-plugin ./output # 在 ./output/ 下建 my-plugin/ +# +# 说明: +# - 自动生成双端 manifest(.claude-plugin/plugin.json 与 .codex-plugin/plugin.json), +# 字段末尾已预留占位注释,可直接照着文档填。 +# - 其余目录(agents/ skills/ hooks/ scripts/ ...)为示例骨架,按需增删。 +# - 生成的两个 plugin.json 目前是 JSONC(含 // 注释)模板;正式发布前请将其转为 +# 标准 JSON(可用 claude plugin validate 校验 / Codex 侧用官方校验器)。 +# +set -euo pipefail + +# ---------- 参数解析 ---------- +PLUGIN_NAME="${1:-}" +TARGET="${2:-.}" + +if [[ -z "$PLUGIN_NAME" ]]; then + echo "用法: $0 [目标路径]" >&2 + echo " 例: $0 my-plugin" >&2 + exit 1 +fi + +# 规格化为 kebab-case(全小写、下划线/空格转连字符),兼容 bash 3.2(不带 ${K,,}) +K="$(printf '%s' "$PLUGIN_NAME" | tr '[:upper:]' '[:lower:]' | tr '_' ' ' | tr -s ' ' | tr ' ' '-')" +K="${K#.}" +K="${K%%/}" +K="${K##/}" +if [[ -z "$K" || "$K" == *"../"* || "$K" == /* ]]; then + echo "错误: plugin 名 \"$PLUGIN_NAME\" 无效,应为 kebab-case 名称(字母/数字/连字符)" >&2 + exit 1 +fi + +DIR="${TARGET}/${K}" +mkdir -p "$DIR" +cd "$DIR" + +# 占位符;sed 替换(用临时文件以兼容 mac 的 sed) +PH="__PLUGIN_NAME__" +apply_ph_name() { + local f="$1" + sed "s/${PH}/${K}/g" "$f" > "$f.tmp" && mv "$f.tmp" "$f" +} + +# ---------- 1. Claude Code manifest ---------- +mkdir -p ".claude-plugin" +cat > ".claude-plugin/plugin.json" <<'EOF' +{ + // Claude Code Plugin 清单(完整参考:https://code.claude.com/docs/en/plugins-reference) + "$schema": "https://json.schemastore.org/claude-plugin.json", // 可选:editor 补全用;加载时忽略(占位 URL) + "name": "__PLUGIN_NAME__", // 必填:kebab-case 唯一标识,组件命名空间前缀(如 __PLUGIN_NAME__:agent) + "displayName": "", // 可选:UI 展示的可读名,可含空格/大小写 + "version": "1.0.0", // 可选:语义化版本;改动此字段才会触发升级 + "description": "", // 可选:插件一句话简介 + "author": { // 可选:作者归属 + "name": "", // 姓名 + "email": "", // 邮箱 + "url": "" // 主页 + }, + "homepage": "", // 可选:文档 URL + "repository": "", // 可选:源码仓库 URL + "license": "", // 可选:许可证标识,如 "MIT" + "keywords": [], // 可选:发现用标签 + "metadata": {}, // 可选:自由数据,Claude Code 完全忽略(可用于跨工具清单) + "defaultEnabled": true, // 可选:是否默认启用,默认 true + // --- 下列字段指向自定义组件目录/文件,缺省时走默认目录 --- + "skills": "", // 可选:自定义 skill 目录(追加到默认 skills/) + "commands": "", // 可选:扁平 .md 文件(替换默认 commands/) + "agents": "", // 可选:自定义 agent 文件(替换默认 agents/) + "workflows": "", // 可选:自定义 workflow 脚本(替换默认 workflows/) + "hooks": "", // 可选:hook 配置路径或内联配置 + "mcpServers": "", // 可选:MCP 配置路径或内联配置 + "outputStyles": "", // 可选:自定义输出样式(替换默认 output-styles/) + "lspServers": "", // 可选:LSP server 配置 + "experimental": { // 可选:实验性组件 + "themes": "", // 主题文件/目录(替换默认 themes/) + "monitors": "" // 后台 monitor 配置(替换默认 monitors/) + }, + "userConfig": {}, // 可选:启用时向用户询问的值,如 { "api_endpoint": { "type": "string", "title": "", "description": "" } } + "channels": [], // 可选:消息通道声明,如 { "server": "telegram", "userConfig": {} } + "dependencies": [ // 可选:依赖的其他 plugin(字符串或 {name, version}) + // "helper-lib", + // { "name": "secrets-vault", "version": "~2.1.0" } + ] +} +EOF +apply_ph_name ".claude-plugin/plugin.json" + +# ---------- 2. Codex manifest ---------- +mkdir -p ".codex-plugin" +cat > ".codex-plugin/plugin.json" <<'EOF' +{ + // Codex Plugin 清单(参考 OpenAI 模板:https://developers.openai.com/plugins/build/plugins) + "name": "__PLUGIN_NAME__", // 必填:kebab-case 插件唯一标识,也是组件命名空间 + "version": "0.1.0", // 可选:版本号;可加构建缀,如 0.1.0+codex.YYYYMMDD + "description": "", // 可选:插件一句话简介 + "author": { // 可选:作者 + "name": "" // 姓名 + }, + "homepage": "", // 可选:文档 / 项目主页 URL + "repository": "", // 可选:源码仓库 URL + "license": "", // 可选:许可证标识,如 "MIT" + "keywords": [], // 可选:发现用关键词 + "skills": "./skills/", // 可选:自定义 skill 目录(缺省即 ./skills/) + "agents": "./agents/", // 可选:自定义 agent 目录(缺省即 ./agents/) + "hooks": "./hooks/codex-hooks.json", // 可选:hook 配置路径(对应 hooks/codex-hooks.json) + "interface": { // 可选:安装面(store)展示配置 + "displayName": "", // 插件标题 + "shortDescription": "", // 一句话简介 + "longDescription": "", // 详细描述 + "developerName": "", // 开发者名 + "category": "Coding", // 分类,如 "Coding" / "Productivity" + "capabilities": [ // 声明所需能力 + "Interactive", + "Read", + "Write", + "Bash" + ], + "defaultPrompt": [], // 首次使用推荐提示词 + "brandColor": "" // 品牌色(hex),如 "#8B5CF6" + } +} +EOF +apply_ph_name ".codex-plugin/plugin.json" + +# ---------- 3. agents(Claude: .md;Codex: .toml)---------- +mkdir -p "agents/claude" +cat > "agents/claude/${K}-assistant.md" <<'EOF' +--- +description: 一句话说明该 agent 何时被调用(Claude Code 依据 description 决定是否使用) +name: __PLUGIN_NAME__-assistant +tools: Read, Write, Edit +model: sonnet +--- + +# __PLUGIN_NAME__ assistant + +在这里编写该 agent 的系统提示词 / 职责说明。 +EOF + +cat > "agents/${K}-assistant.toml" <<'EOF' +# Codex 专属 Agent 定义(骨架占位,字段以 OpenAI Codex / Agent Plugins 规范为准) +name = "__PLUGIN_NAME__-assistant" +description = "一句话说明该 agent 何时被调用" +model = "gemini-2.5-pro" # 占位:按需替换供应商与型号 + +# 如需绑定 skill,可在此列出 +# skills = [""] +EOF +apply_ph_name "agents/claude/${K}-assistant.md" +apply_ph_name "agents/${K}-assistant.toml" + +# ---------- 4. scripts(可选,存放自研脚本)---------- +mkdir -p "scripts" + +# ---------- 5. skills(示例骨架)---------- +mkdir -p "skills/demo" +cat > "skills/demo/SKILL.md" <<'EOF' +--- +description: 一句话说明该 skill 何时被调用(Claude Code 与 Codex 都依据它判断是否调用) +--- + +# demo + +在这里编写该 skill 的具体指令内容。 +EOF + +# ---------- 6. hooks ---------- +mkdir -p "hooks" +cat > "hooks/claude-hooks.json" <<'EOF' +{ + // Claude Code Hook 配置(参考:https://code.claude.com/docs/en/hooks) + "hooks": { + "PreToolUse": [], // 工具调用前触发 + "PostToolUse": [ // 工具调用后触发 + { + "matcher": "Write|Edit", + "hooks": [ // 同一事件可挂多个命令 + { "type": "command", "command": "" } // 退出码 0 表示通过;输入从 stdin 传入 JSON + ] + } + ] + } +} +EOF + +cat > "hooks/codex-hooks.json" <<'EOF' +{ + // Codex Hook 配置(骨架占位,字段以 OpenAI Codex 插件 hooks 规范为准) + "hooks": [] +} +EOF + +# ---------- 7. MCP 配置(可选)---------- +cat > ".mcp.json" <<'EOF' +{ + // 项目级 MCP server 配置(Claude Code / Codex 通用) + "mcpServers": {} // 例:{ "my-server": { "type": "stdio", "command": "npx", "args": ["-y", "mcp-server"] } } +} +EOF + +# ---------- 8. assets(Codex interface 图标等)---------- +mkdir -p "assets" + +# ---------- 9. README ---------- +cat > "README.md" <<'EOF' +# __PLUGIN_NAME__ + +一个同时支持 **Claude Code** 与 **Codex** 的跨端 plugin 骨架。 + +## 目录结构 + +``` +./ +├── .claude-plugin/ # Claude Code 配置 +│ └── plugin.json # Claude Plugin Manifest +├── .codex-plugin/ # Codex 配置 +│ └── plugin.json # Codex Plugin Manifest +├── agents/ # Agent 定义(按需) +│ ├── claude/__PLUGIN_NAME__-assistant.md # Claude Code 专属 Agent +│ └── __PLUGIN_NAME__-assistant.toml # Codex 专属 Agent +├── scripts/ # 自研脚本(按需) +├── skills/ # Skill +│ └── demo/SKILL.md +├── hooks/ # Hook(按需) +│ ├── claude-hooks.json # Claude Code Hook +│ └── codex-hooks.json # Codex Hook +├── .mcp.json # MCP 配置(按需) +├── assets/ # Codex interface 图标 / logo / 截图 +└── README.md +``` + +## 相关文档 + +- Claude Code Plugins:https://code.claude.com/docs/en/plugins +- Codex Plugins(Agent Plugins):https://developers.openai.com/plugins/build/plugins + +## 兼容性说明 + +两个 `plugin.json` 目前是 **JSONC(含 `//` 注释)模板**,便于对照文档填写。 +正式发布 / 交给对应 CLI 加载前,请移除注释转成标准 JSON: + +- Claude Code:运行 `claude plugin validate .` 校验 +- Codex:按官方校验器 / CLI 校验 +EOF +apply_ph_name "README.md" + +# ---------- 完成 ---------- +echo "✔ 已生成 plugin: $K" +echo " 位置:$(pwd)" +echo +echo "目录结构:" +find . -type f | sort | sed 's/^\.\// └── /' \ No newline at end of file diff --git a/app/public/install.sh b/app/public/install.sh new file mode 100755 index 00000000..3fab91f1 --- /dev/null +++ b/app/public/install.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 安装/更新 agent-market plugin marketplace(本地路径模式)。 +# +# 背景:plugin marketplace 的 source 需是 git 仓库或本地路径;agent-market 走 +# 172.16.100.225 HTTP 静态分发(目录索引关闭),因此先下载源码归档到本地 +# ~/.agents/agent-market/,再用本地路径作为 marketplace source。 +# +# 用法: +# curl .../install.sh | bash # 下载源码 + 打印安装命令 +# curl .../install.sh | bash -s -- # 下载源码 + 自动注册并安装指定 plugin +# +# 环境变量覆盖: +# AGENT_MARKET_BASE_URL Agent Market HTTP 服务地址 +# AGENT_MARKET_LOCAL_DIR 本地 marketplace 目录,默认: ~/.agents/agent-market +# AGENT_MARKET_NAME marketplace 名,默认: agent-market + +AGENT_NAME="${1:-}" +AGENT_MARKET_BASE_URL="${AGENT_MARKET_BASE_URL:-http://172.16.100.225:7001/agent-market}" +AGENT_MARKET_BASE_URL="${AGENT_MARKET_BASE_URL%/}" +AGENT_MARKET_LOCAL_DIR="${AGENT_MARKET_LOCAL_DIR:-$HOME/.agents/agent-market}" +AGENT_MARKET_NAME="${AGENT_MARKET_NAME:-agent-market}" +DORAEMON_URL="${AGENT_MARKET_BASE_URL%/agent-market}" +SRC_URL="$DORAEMON_URL/api/agents/download?name=$AGENT_NAME" +PREFLIGHT_MISSING=0 + +log() { printf '%s\n' "$*"; } +ok() { printf ' ✓ %s\n' "$*"; } +warn() { printf '⚠️ %s\n' "$*" >&2; } +die() { printf '✗ %s\n' "$*" >&2; exit 1; } + +# 解析宿主 CLI:优先 PATH 里的独立 CLI,否则用桌面 App 内置 CLI。实测 Codex App +#(ChatGPT.app)内置 /Applications/ChatGPT.app/Contents/Resources/codex,与独立 CLI +# 共享 ~/.codex/config.toml,可正常执行 plugin 子命令。 +resolve_codex() { + if command -v codex >/dev/null 2>&1; then + printf '%s\n' "codex" + elif [[ -x "/Applications/ChatGPT.app/Contents/Resources/codex" ]]; then + printf '%s\n' "/Applications/ChatGPT.app/Contents/Resources/codex" + else + return 1 + fi +} +resolve_claude() { + command -v claude >/dev/null 2>&1 && { printf '%s\n' "claude"; return 0; } + return 1 +} + +command -v curl >/dev/null 2>&1 || die "需要安装 curl" +command -v unzip >/dev/null 2>&1 || die "需要安装 unzip" +command -v python3 >/dev/null 2>&1 || die "需要安装 python3" + +if [[ -z "$AGENT_NAME" ]]; then + die "GitOps 模式下必须指定 Agent 名称,例如: curl .../install.sh | bash -s -- bugfix-agent" +fi + +if [[ ! "$AGENT_NAME" =~ ^[A-Za-z0-9._-]+$ ]]; then + die "无效的 Agent 名称: $AGENT_NAME" +fi + +case "$AGENT_MARKET_LOCAL_DIR" in + ""|"/"|"$HOME") die "不安全的 AGENT_MARKET_LOCAL_DIR: $AGENT_MARKET_LOCAL_DIR" ;; +esac + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +log "" +log "下载 Agent源码归档: $SRC_URL" +curl -fsSL "$SRC_URL" -o "$TMP_DIR/agent.zip" \ + || die "下载失败: $SRC_URL" + +mkdir -p "$TMP_DIR/extracted" +unzip -q "$TMP_DIR/agent.zip" -d "$TMP_DIR/extracted" \ + || die "归档解压失败" + +SOURCE_DIR="$TMP_DIR/extracted/$AGENT_NAME" +[[ -d "$SOURCE_DIR" ]] || die "解压后未找到预期目录: $SOURCE_DIR" + +# Move the single agent to the centralized agent-market folder +AGENT_TARGET_DIR="$AGENT_MARKET_LOCAL_DIR/agents/$AGENT_NAME" +mkdir -p "$(dirname "$AGENT_TARGET_DIR")" +rm -rf "$AGENT_TARGET_DIR" +mv "$SOURCE_DIR" "$AGENT_TARGET_DIR" + +# Auto-generate or update the central marketplace.json +mkdir -p "$AGENT_MARKET_LOCAL_DIR/.claude-plugin" +mkdir -p "$AGENT_MARKET_LOCAL_DIR/.codex-plugin" + +python3 - </dev/null | grep -F "\"name\": \"$AGENT_MARKET_NAME\"" >/dev/null; then + ok "marketplace $AGENT_MARKET_NAME 已注册,跳过" + else + "$cli" plugin marketplace add "$AGENT_MARKET_LOCAL_DIR" \ + || die "codex marketplace add 失败" + ok "marketplace $AGENT_MARKET_NAME 已注册" + fi + # 不用 grep -Fq:命中即关闭读端,CLI 输出超管道缓冲时被 SIGPIPE 杀死,pipefail 下会误判未安装。 + if "$cli" plugin list --json 2>/dev/null | grep -F "\"pluginId\": \"$AGENT_NAME@$AGENT_MARKET_NAME\"" >/dev/null; then + ok "plugin $AGENT_NAME 已安装,跳过" + else + "$cli" plugin add "$AGENT_NAME@$AGENT_MARKET_NAME" \ + || die "codex plugin add 失败" + ok "plugin $AGENT_NAME 已安装" + fi +} + +install_claude() { + local cli="$1" + local known="$HOME/.claude/plugins/known_marketplaces.json" + local installed="$HOME/.claude/plugins/installed_plugins.json" + log "" + log "Claude Code 安装 $AGENT_NAME@$AGENT_MARKET_NAME ..." + if [[ -f "$known" ]] && grep -Fq "\"$AGENT_MARKET_NAME\"" "$known"; then + ok "marketplace $AGENT_MARKET_NAME 已注册,跳过" + else + "$cli" plugin marketplace add "$AGENT_MARKET_LOCAL_DIR" \ + || die "claude marketplace add 失败" + ok "marketplace $AGENT_MARKET_NAME 已注册" + fi + if [[ -f "$installed" ]] && grep -Fq "\"$AGENT_NAME\"" "$installed"; then + ok "plugin $AGENT_NAME 已安装,跳过" + else + # `--yes` 用于跳过 declare-command 插件安装的确认(非 TTY 下必需),但仅较新 CLI 支持; + # 旧版本(如 v2.1.119)不认该选项会直接报错。先捕获 `plugin install --help` 输出再 grep + # (含 stderr,兼容 help 打到 stderr 的 CLI)——管道 + grep -q 在输出超管道缓冲时会被 + # SIGPIPE 误判为未匹配,捕获方式无此问题。追加与探测同一字符串 --yes,避免仅支持长选项 + # 的 CLI 拒绝 -y 别名。 + local yes_flag="" help_out + help_out="$("$cli" plugin install --help 2>&1 || true)" + if grep -q -- '--yes' <<<"$help_out"; then + yes_flag="--yes" + fi + "$cli" plugin install "$AGENT_NAME@$AGENT_MARKET_NAME" ${yes_flag:+"--yes"} \ + || die "claude plugin install 失败" + ok "plugin $AGENT_NAME 已安装" + fi +} + +# plugin 机制不自动装依赖;所有依赖 Skill 已随插件 skills/ 快照分发,无需全局安装。 +# 安装前预检:Agent 目录自带 setup.sh 时运行,探测运行时依赖的环境变量/工具, +# 产出 setup-report(行格式: ),最终由 render_preflight_report 渲染。 +run_preflight() { + local setup="$AGENT_MARKET_LOCAL_DIR/agents/$AGENT_NAME/setup.sh" + [[ -f "$setup" ]] || return 0 + log "" + log "运行 Agent 安装前检查(setup)..." + ( + cd "$(dirname "$setup")" + AGENT_DIR="$(dirname "$setup")" \ + AGENT_NAME="$AGENT_NAME" \ + SETUP_REPORT="$TMP_DIR/setup-report" \ + bash ./setup.sh + ) || warn "Agent 环境检查未通过,继续安装" +} + +render_preflight_report() { + local report="$TMP_DIR/setup-report" + [[ -f "$report" ]] || return 0 + + local kind state args name reason + local tools_shown=0 env_shown=0 + + log "" + log "【环境检查结论】" + while read -r kind state args; do + [[ -n "$kind" ]] || continue + if [[ "$state" == "MISSING" ]]; then + PREFLIGHT_MISSING=1 + fi + if [[ "$kind" == "TOOL" && "$tools_shown" -eq 0 ]]; then + log "" + log "运行环境:" + tools_shown=1 + fi + if [[ "$kind" == "ENV" && "$env_shown" -eq 0 ]]; then + log "" + log "环境变量配置情况:" + env_shown=1 + fi + if [[ "$state" == "CONFIGURED" ]]; then + ok "${args// /、}" + elif [[ "$kind" == "TOOL" ]]; then + printf ' ❌ %s(未安装)\n' "${args// /、}" + else + name="${args%% *}" + reason="${args#* }" + if [[ -n "$reason" && "$reason" != "$name" ]]; then + printf ' ❌ %s(未配置,%s)\n' "$name" "$reason" + else + printf ' ❌ %s(未配置)\n' "$name" + fi + fi + done < "$report" +} + +run_preflight + +if [[ -n "$CODEX" ]]; then + install_codex "$CODEX" +fi +if [[ -n "$CLAUDE" ]]; then + install_claude "$CLAUDE" +fi + +render_preflight_report + +log "" +if [[ "$PREFLIGHT_MISSING" -eq 1 ]]; then + log "【⚠️ 安装结束】存在未就绪项(缺失工具 / 未配置环境变量),请按上方提示处理后使用" +else + log "【✅ 安装完成】$AGENT_NAME" +fi +ENTRYPOINT="$(read_entrypoint)" +if [[ -n "$ENTRYPOINT" ]]; then + [[ -n "$CODEX" ]] && log "Codex 调用: \$$ENTRYPOINT" + [[ -n "$CLAUDE" ]] && log "Claude Code 调用: /$AGENT_NAME:$ENTRYPOINT" +fi +log "" diff --git a/app/router.js b/app/router.js index a30a8133..e19d4c96 100644 --- a/app/router.js +++ b/app/router.js @@ -170,7 +170,9 @@ module.exports = (app) => { app.get('/api/agents/related', app.controller.agents.getRelatedAgents); app.get('/api/agents/asset', app.controller.agents.getAgentAsset); app.get('/api/agents/download', app.controller.agents.downloadAgentArchive); - app.post('/api/agents/import-file', app.controller.agents.importAgentFile); + app.post('/api/agents/import-git', app.controller.agents.importAgentFromGit); + app.post('/api/agents/sync-git', app.controller.agents.syncGitAgents); + app.post('/api/agents/update-git-config', app.controller.agents.updateAgentGitConfig); app.post('/api/agents/delete', app.controller.agents.deleteAgent); /** diff --git a/app/schedule/syncGitAgents.js b/app/schedule/syncGitAgents.js new file mode 100644 index 00000000..267858f4 --- /dev/null +++ b/app/schedule/syncGitAgents.js @@ -0,0 +1,25 @@ +module.exports = (app) => { + const autoSyncInterval = app.config.agentMarket?.autoSyncInterval; + return { + schedule: { + interval: autoSyncInterval || '5m', + type: 'worker', // 仅由单个 worker 执行,避免多 worker 并发冲突 + immediate: false, // 应用启动后等待到达周期再执行 + disable: !autoSyncInterval || autoSyncInterval === '0', + }, + // 定时轮询同步所有已配置 GitLab 仓库的 Agent 插件 + async task(ctx) { + try { + ctx.logger.info('[schedule:syncGitAgents] 开始执行 Agent 仓库定时同步'); + const results = await ctx.service.agents.syncAllGitAgents(); + const successCount = results.filter((item) => item.success).length; + const changedCount = results.filter((item) => item.isContentChanged).length; + ctx.logger.info( + `[schedule:syncGitAgents] 同步执行完成: 总数 ${results.length},成功 ${successCount},有代码变动 ${changedCount}` + ); + } catch (error) { + ctx.logger.error(`[schedule:syncGitAgents] 定时同步异常: ${error.message}`); + } + }, + }; +}; diff --git a/app/service/agents.js b/app/service/agents.js index 41ded8f8..69262b68 100644 --- a/app/service/agents.js +++ b/app/service/agents.js @@ -1,9 +1,11 @@ const Service = require('egg').Service; -const AdmZip = require('adm-zip'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const mime = require('mime-types'); +const { execFile } = require('child_process'); +const util = require('util'); +const execFileAsync = util.promisify(execFile); const { normalizeRelativePath, extractSkillMdName } = require('../utils/skill-utils'); const { resolveSkillIdentifier, sanitizeInstallKeySegment } = require('../utils/skill-install-key'); @@ -15,6 +17,11 @@ const { const AGENT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const GIT_BRANCH_PATTERN = /^[a-zA-Z0-9_][a-zA-Z0-9_.\-/]*$/; +const DEFAULT_GIT_TIMEOUT_MS = 60000; + +// 正在执行 Git 同步的仓库集合,防止并发执行导致 index.lock 冲突 +const activeSyncRepos = new Set(); class AgentsService extends Service { constructor(ctx) { @@ -26,11 +33,6 @@ class AgentsService extends Service { getAgentMarketConfig() { return { storageDir: '/data/doraemon/agent-market', - maxZipSize: 50 * 1024 * 1024, - maxExtractedSize: 200 * 1024 * 1024, - maxFileCount: 500, - maxSingleFileSize: 20 * 1024 * 1024, - maxImageSize: 5 * 1024 * 1024, ...this.app.config.agentMarket, }; } @@ -43,15 +45,15 @@ class AgentsService extends Service { } this.storageReadyPromise = (async () => { - const { Agent, AgentFile, AgentSkill } = this.app.model; - if (!Agent || !AgentFile || !AgentSkill) { + const { Agent, AgentSkill } = this.app.model; + if (!Agent || !AgentSkill) { this.ctx.throw(500, 'Agent 数据模型未加载'); } await Agent.sync(); - await AgentFile.sync(); await AgentSkill.sync(); await this.ensureAgentSkillsTableCompatible(); + await this.ensureAgentsTableCompatible(); this.storageReady = true; })(); @@ -81,6 +83,52 @@ class AgentsService extends Service { } } + // 兼容历史 agents 表结构,添加 git_url 和 git_branch + async ensureAgentsTableCompatible() { + try { + const queryInterface = this.app.model?.getQueryInterface?.(); + if (!queryInterface?.describeTable || !queryInterface?.addColumn) return; + const table = await queryInterface.describeTable('agents'); + if (!table?.git_url) { + await queryInterface.addColumn('agents', 'git_url', { + type: this.app.Sequelize.STRING(1000), + allowNull: true, + comment: 'GitLab 仓库地址', + }); + } + if (!table?.git_branch) { + await queryInterface.addColumn('agents', 'git_branch', { + type: this.app.Sequelize.STRING(100), + allowNull: true, + comment: 'GitLab 仓库分支', + }); + } + if (!table?.last_git_refresh_at) { + await queryInterface.addColumn('agents', 'last_git_refresh_at', { + type: this.app.Sequelize.DATE, + allowNull: true, + comment: '最近一次刷新/检查 Git 时间', + }); + } + if (!table?.last_git_sync_at) { + await queryInterface.addColumn('agents', 'last_git_sync_at', { + type: this.app.Sequelize.DATE, + allowNull: true, + comment: '最近一次代码变动同步时间', + }); + } + if (table?.logo_size && table.logo_size.allowNull === false) { + await queryInterface.changeColumn('agents', 'logo_size', { + type: this.app.Sequelize.INTEGER, + allowNull: true, + comment: 'Logo 大小', + }); + } + } catch (error) { + this.ctx?.logger?.warn?.(`[agents] 兼容检查 agents 表结构失败: ${error.message}`); + } + } + normalizeAgentPath(filePath, message = '非法文件路径') { const normalized = normalizeRelativePath(String(filePath || '').replace(/^\.\//, '')); if (!normalized) { @@ -102,29 +150,6 @@ class AgentsService extends Service { } } - isLikelyBinary(buffer) { - if (!buffer || buffer.length === 0) return false; - const sample = buffer.subarray(0, Math.min(buffer.length, 4096)); - if (sample.includes(0)) return true; - try { - new TextDecoder('utf-8', { fatal: true }).decode(buffer); - return false; - } catch { - return true; - } - } - - getZipEntryMode(entry) { - const attr = Number(entry?.attr || entry?.header?.attr || 0); - const mode = (attr >>> 16) & 0xffff; - return mode || 0o644; - } - - isSymbolicLink(entry) { - const mode = this.getZipEntryMode(entry); - return (mode & 0o170000) === 0o120000; - } - validateAgentName(name) { const value = String(name || '').trim(); if (!AGENT_NAME_PATTERN.test(value) || value.length > 100) { @@ -141,37 +166,6 @@ class AgentsService extends Service { return value; } - parseSemver(version) { - const match = String(version || '') - .trim() - .match(SEMVER_PATTERN); - if (!match) { - this.ctx.throw(400, 'metadata.version 必须是有效的 SemVer 格式'); - } - - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - prerelease: match[4] || '', - }; - } - - compareAgentVersion(left, right) { - const a = this.parseSemver(left); - const b = this.parseSemver(right); - const keys = ['major', 'minor', 'patch']; - for (const key of keys) { - if (a[key] > b[key]) return 1; - if (a[key] < b[key]) return -1; - } - - if (!a.prerelease && !b.prerelease) return 0; - if (!a.prerelease) return 1; - if (!b.prerelease) return -1; - return a.prerelease.localeCompare(b.prerelease); - } - buildAssetUrl(agentName, assetPath) { return `/api/agents/asset?name=${encodeURIComponent(agentName)}&path=${encodeURIComponent( assetPath @@ -310,455 +304,12 @@ class AgentsService extends Service { }; } - buildContentHash(records) { - const hash = crypto.createHash('sha256'); - records - .slice() - .sort((left, right) => left.filePath.localeCompare(right.filePath)) - .forEach((item) => { - hash.update(item.filePath); - hash.update('\0'); - hash.update(item.buffer); - hash.update('\0'); - }); - return hash.digest('hex'); - } - - async parseAgentZip(zipPath) { - const config = this.getAgentMarketConfig(); - let zip; - - try { - zip = new AdmZip(zipPath); - } catch (error) { - this.ctx.throw(400, `解析 .zip 文件失败: ${error.message}`); - } - - const entries = zip.getEntries().filter((entry) => { - const normalizedName = String(entry.entryName || '').replace(/\\/g, '/'); - if (!normalizedName) return false; - if (normalizedName.startsWith('__MACOSX/')) return false; - if (normalizedName.endsWith('.DS_Store')) return false; - return true; - }); - - const fileEntries = entries.filter((entry) => !entry.isDirectory); - if (fileEntries.length === 0) { - this.ctx.throw(400, '.zip 包内未发现有效文件'); - } - if (fileEntries.length > config.maxFileCount) { - this.ctx.throw(400, `文件数量超过限制: ${config.maxFileCount}`); - } - - const caseInsensitivePaths = new Set(); - const topLevelDirs = new Set(); - const fileRecords = []; - const fileMap = new Map(); - let extractedSize = 0; - - // 逐个 ZIP 条目校验路径、大小和特殊文件 - fileEntries.forEach((entry) => { - if (this.isSymbolicLink(entry)) { - this.ctx.throw(400, `不支持软链接: ${entry.entryName}`); - } - - const normalized = this.normalizeAgentPath(entry.entryName); - const lowerCasePath = normalized.toLowerCase(); - if (caseInsensitivePaths.has(lowerCasePath)) { - this.ctx.throw(400, `检测到重复路径: ${normalized}`); - } - caseInsensitivePaths.add(lowerCasePath); - - const buffer = entry.getData(); - if (buffer.length > config.maxSingleFileSize) { - this.ctx.throw(400, `文件超过大小限制: ${normalized}`); - } - - extractedSize += buffer.length; - if (extractedSize > config.maxExtractedSize) { - this.ctx.throw(400, `解压后总大小超过限制: ${config.maxExtractedSize}`); - } - - const [topLevel] = normalized.split('/'); - if (topLevel) { - topLevelDirs.add(topLevel); - } - - fileRecords.push({ - entry, - filePath: normalized, - buffer, - size: buffer.length, - }); - fileMap.set(normalized, { - entry, - buffer, - size: buffer.length, - }); - }); - - if (topLevelDirs.size !== 1) { - this.ctx.throw(400, 'ZIP 顶层必须且只能包含一个 Agent 目录'); - } - - const [rootDir] = [...topLevelDirs]; - const pluginJsonPath = `${rootDir}/.codex-plugin/plugin.json`; - const pluginJsonEntry = fileMap.get(pluginJsonPath); - if (!pluginJsonEntry) { - this.ctx.throw(400, 'ZIP 中缺少根目录 .codex-plugin/plugin.json'); - } - const claudePluginJsonPath = `${rootDir}/.claude-plugin/plugin.json`; - const claudePluginJsonEntry = fileMap.get(claudePluginJsonPath); - if (!claudePluginJsonEntry) { - this.ctx.throw(400, 'ZIP 中缺少根目录 .claude-plugin/plugin.json'); - } - - const relativeFileMap = new Map(); - fileRecords.forEach((item) => { - const relativePath = item.filePath.slice(rootDir.length + 1); - if (!relativePath) return; - relativeFileMap.set(relativePath, { - ...item, - relativePath, - }); - }); - - const manifest = this.parseCodexPluginJson(pluginJsonEntry.buffer.toString('utf8')); - const claudeManifest = this.parseClaudePluginJson( - claudePluginJsonEntry.buffer.toString('utf8') - ); - const validated = this.validateCodexManifest(manifest); - const validatedClaude = this.validateClaudeManifest(claudeManifest); - if (validated.name !== rootDir || validatedClaude.name !== validated.name) { - this.ctx.throw(400, '两个 plugin manifest 的 name 必须与 Agent 目录名一致'); - } - if (validatedClaude.version && validatedClaude.version !== validated.version) { - this.ctx.throw(400, '两个 plugin manifest 的 version 必须一致'); - } - if ( - ![...relativeFileMap.keys()].some( - (filePath) => - filePath === validated.skills || filePath.startsWith(`${validated.skills}/`) - ) - ) { - this.ctx.throw(400, `Codex skills 路径不存在: ./${validated.skills}`); - } - validatedClaude.agents.forEach((agentPath) => { - if (!relativeFileMap.has(agentPath)) { - this.ctx.throw(400, `Claude agent 文件不存在: ./${agentPath}`); - } - }); - const contentHash = this.buildContentHash( - fileRecords.map((item) => ({ - filePath: item.filePath, - buffer: item.buffer, - })) - ); - - // logo 从包内 assets/logo.png 读取(支持 png/jpeg/webp),随 resource 落盘并记录元数据 - const LOGO_ALLOWED = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.webp']; - let logo = null; - const assetFiles = []; - const logoPaths = validated.logoRef - ? [validated.logoRef] - : [ - ...LOGO_ALLOWED.map((name) => `assets/${name}`), - ...LOGO_ALLOWED.map((name) => `.codex-plugin/assets/${name}`), - ]; - const hasExplicitLogo = Boolean(validated.logoRef); - for (const relativeLogoPath of logoPaths) { - const logoName = path.basename(relativeLogoPath); - // 兼容仓库内 assets 与 Codex 官方示例使用的 .codex-plugin/assets 两种布局 - const isSupportedLogoPath = - relativeLogoPath.startsWith('assets/') || - relativeLogoPath.startsWith('.codex-plugin/assets/'); - if (!LOGO_ALLOWED.includes(logoName) || !isSupportedLogoPath) { - this.ctx.throw( - 400, - 'interface.logo 仅支持 assets/logo.{png,jpg,jpeg,webp} 或 .codex-plugin/assets/logo.{png,jpg,jpeg,webp}' - ); - } - const logoEntry = fileMap.get(`${rootDir}/${relativeLogoPath}`); - if (!logoEntry) { - if (hasExplicitLogo) { - this.ctx.throw(400, `Logo 文件不存在: ./${relativeLogoPath}`); - } - continue; - } - if (logoEntry.size > config.maxImageSize) { - this.ctx.throw(400, `Logo 文件超过大小限制: ./${relativeLogoPath}`); - } - const mimeType = mime.lookup(logoName) || 'application/octet-stream'; - logo = { - path: `${validated.name}/${contentHash}/${relativeLogoPath}`, - mimeType, - size: logoEntry.size, - hash: this.buildContentHash([ - { filePath: relativeLogoPath, buffer: logoEntry.buffer }, - ]), - }; - assetFiles.push({ - path: logo.path, - buffer: logoEntry.buffer, - }); - break; - } - - const files = [...relativeFileMap.values()] - .filter( - (item) => - !item.relativePath.startsWith('assets/') && - !item.relativePath.startsWith('.codex-plugin/assets/') - ) - .map((item) => { - const isBinary = this.isLikelyBinary(item.buffer); - return { - filePath: item.relativePath, - mimeType: mime.lookup(item.relativePath) || 'application/octet-stream', - size: item.size, - isBinary, - encoding: isBinary ? 'base64' : 'utf8', - mode: this.getZipEntryMode(item.entry), - content: isBinary - ? item.buffer.toString('base64') - : item.buffer.toString('utf8'), - }; - }); - - // 解析 Agent 包内 skills 目录下的 SKILL.md,得到关联 Skill 的标识列表 - const agentSkills = []; - if (validated.skills) { - const skillsPrefix = `${validated.skills}/`; - [...relativeFileMap.values()].forEach((item) => { - if (!item.relativePath.startsWith(skillsPrefix)) return; - if (path.basename(item.relativePath).toLowerCase() !== 'skill.md') return; - - const content = item.buffer.toString('utf8'); - let name = extractSkillMdName(content).trim(); - if (!name) { - const dir = path.posix.dirname(item.relativePath); - name = dir.split('/').pop() || ''; - } - if (!name) return; - if (!agentSkills.includes(name)) agentSkills.push(name); - }); - } - - return { - agent: { - name: validated.name, - displayName: validated.displayName, - version: validated.version, - description: validated.description, - longDescription: validated.longDescription, - authorName: validated.authorName, - category: validated.category, - keywords: validated.keywords, - defaultPrompt: validated.defaultPrompt, - capabilities: validated.capabilities, - skills: agentSkills, - logo, - contentHash, - fileCount: fileRecords.length, - }, - files, - assetFiles, - }; - } - - async writeAssetFiles(assetFiles = []) { - const storageDir = this.getAgentMarketConfig().storageDir; - const touchedDirs = new Set(); - - assetFiles.forEach((item) => { - const absolutePath = path.join(storageDir, item.path); - const parentDir = path.dirname(absolutePath); - fs.mkdirSync(parentDir, { recursive: true }); - fs.writeFileSync(absolutePath, item.buffer); - touchedDirs.add(path.join(storageDir, item.path.split('/').slice(0, 2).join('/'))); - }); - - return touchedDirs; - } - - async writeAgentArchive(agent, sourcePath) { - const storageDir = this.getAgentMarketConfig().storageDir; - const archiveDir = path.join(storageDir, agent.name, agent.contentHash); - const archivePath = path.join(archiveDir, `${agent.name}.zip`); - fs.mkdirSync(archiveDir, { recursive: true }); - fs.copyFileSync(sourcePath, archivePath); - return archiveDir; - } - + // 递归删除指定路径目录 removeDirectory(targetPath) { if (!targetPath || !fs.existsSync(targetPath)) return; fs.rmSync(targetPath, { recursive: true, force: true }); } - async importAgentFile(params = {}, file) { - if (!file?.filename || !file?.filepath) { - this.ctx.throw(400, '上传文件无效'); - } - if (!String(file.filename).toLowerCase().endsWith('.zip')) { - this.ctx.throw(400, '仅支持上传 .zip 文件'); - } - - const config = this.getAgentMarketConfig(); - if (file.size && file.size > config.maxZipSize) { - this.ctx.throw(400, `ZIP 文件超过大小限制 ${config.maxZipSize / 1024 / 1024}MB`); - } - - await this.ensureStorageReady(); - - const parsed = await this.parseAgentZip(file.filepath); - const { Agent, AgentFile } = this.app.model; - const existing = await Agent.findOne({ - where: { - name: parsed.agent.name, - }, - }); - - if (existing && Number(existing.is_delete) !== 1) { - const versionDiff = this.compareAgentVersion(parsed.agent.version, existing.version); - if (versionDiff < 0) { - this.ctx.throw( - 400, - `低版本禁止覆盖,当前版本 ${existing.version},导入版本 ${parsed.agent.version}` - ); - } - - if (existing.content_hash === parsed.agent.contentHash) { - await this.writeAgentArchive(parsed.agent, file.filepath); - return { - unchanged: true, - name: parsed.agent.name, - version: parsed.agent.version, - message: '内容未变化', - }; - } - - const confirmed = String(params.confirmOverwrite || '').trim() === 'true'; - if (!confirmed) { - return { - requiresConfirm: true, - name: parsed.agent.name, - currentVersion: existing.version, - incomingVersion: parsed.agent.version, - }; - } - } - - let touchedDirs = new Set(); - - try { - touchedDirs = await this.writeAssetFiles(parsed.assetFiles); - touchedDirs.add(await this.writeAgentArchive(parsed.agent, file.filepath)); - const result = await this.app.model.transaction(async (transaction) => { - let agentId = existing ? existing.id : null; - - const agentPayload = { - name: parsed.agent.name, - display_name: parsed.agent.displayName, - version: parsed.agent.version, - description: parsed.agent.description, - profile: parsed.agent.longDescription, - author_name: parsed.agent.authorName, - category: parsed.agent.category, - tags: JSON.stringify(parsed.agent.keywords || []), - prompts: JSON.stringify( - (parsed.agent.defaultPrompt || []).map((prompt, index) => ({ - title: `开场问题 ${index + 1}`, - prompt, - })) - ), - capabilities: JSON.stringify(parsed.agent.capabilities || []), - logo_path: parsed.agent.logo ? parsed.agent.logo.path : null, - logo_mime_type: parsed.agent.logo ? parsed.agent.logo.mimeType : null, - logo_size: parsed.agent.logo ? parsed.agent.logo.size : null, - logo_hash: parsed.agent.logo ? parsed.agent.logo.hash : null, - content_hash: parsed.agent.contentHash, - source_file_name: file.filename, - file_count: parsed.agent.fileCount, - is_delete: 0, - }; - - if (!existing) { - const created = await Agent.create(agentPayload, { transaction }); - agentId = created.id; - } else { - await Agent.update(agentPayload, { - where: { id: existing.id }, - transaction, - }); - agentId = existing.id; - await AgentFile.destroy({ - where: { agent_id: agentId }, - transaction, - }); - } - - const fileRows = parsed.files.map((item) => ({ - agent_id: agentId, - file_path: item.filePath, - mime_type: item.mimeType, - size: item.size, - is_binary: item.isBinary ? 1 : 0, - encoding: item.encoding, - mode: item.mode, - content: item.content, - is_delete: 0, - })); - - if (fileRows.length > 0) { - await AgentFile.bulkCreate(fileRows, { transaction }); - } - - // 持久化 Agent 关联的 Skill(先清后写,保证与本次包内容一致) - const { AgentSkill } = this.app.model; - const skillSlugs = Array.isArray(parsed.agent.skills) ? parsed.agent.skills : []; - await AgentSkill.destroy({ - where: { agent_id: agentId }, - transaction, - }); - if (skillSlugs.length > 0) { - await AgentSkill.bulkCreate( - skillSlugs.map((skillSlug) => ({ - agent_id: agentId, - skill_slug: skillSlug, - })), - { transaction } - ); - } - - return { - id: agentId, - name: parsed.agent.name, - version: parsed.agent.version, - updated: existing && Number(existing.is_delete) !== 1, - contentHash: parsed.agent.contentHash, - }; - }); - - if ( - existing && - existing.content_hash && - existing.content_hash !== parsed.agent.contentHash - ) { - this.removeDirectory( - path.join( - this.getAgentMarketConfig().storageDir, - `${parsed.agent.name}/${existing.content_hash}` - ) - ); - } - - return result; - } catch (error) { - touchedDirs.forEach((dir) => this.removeDirectory(dir)); - throw error; - } - } - toAgentListItem(row, skillCount = 0) { const resolvedSkillCount = skillCount !== undefined && skillCount !== null @@ -780,6 +331,26 @@ class AgentsService extends Service { : '', logoUrl: row.logo_path ? this.buildAssetUrl(row.name, row.logo_path) : '', skillCount: resolvedSkillCount, + gitUrl: row.git_url || '', + gitBranch: row.git_branch || '', + lastGitRefreshAt: row.last_git_refresh_at + ? typeof row.last_git_refresh_at === 'string' + ? row.last_git_refresh_at + : row.last_git_refresh_at.toISOString() + : row.updated_at + ? typeof row.updated_at === 'string' + ? row.updated_at + : row.updated_at.toISOString() + : '', + lastGitSyncAt: row.last_git_sync_at + ? typeof row.last_git_sync_at === 'string' + ? row.last_git_sync_at + : row.last_git_sync_at.toISOString() + : row.updated_at + ? typeof row.updated_at === 'string' + ? row.updated_at + : row.updated_at.toISOString() + : '', }; } @@ -809,11 +380,13 @@ class AgentsService extends Service { ]; } + // 按 Agent 名称(display_name / name)首字母升序排序 const { count, rows } = await Agent.findAndCountAll({ where, order: [ - ['updated_at', 'DESC'], - ['id', 'DESC'], + ['display_name', 'ASC'], + ['name', 'ASC'], + ['id', 'ASC'], ], offset: (pageNum - 1) * pageSize, limit: pageSize, @@ -955,6 +528,26 @@ class AgentsService extends Service { updatedAt: detail.updated_at ? detail.updated_at.toISOString() : '', skills, skillCount: skills.length, + gitUrl: detail.git_url || '', + gitBranch: detail.git_branch || '', + lastGitRefreshAt: detail.last_git_refresh_at + ? typeof detail.last_git_refresh_at === 'string' + ? detail.last_git_refresh_at + : detail.last_git_refresh_at.toISOString() + : detail.updated_at + ? typeof detail.updated_at === 'string' + ? detail.updated_at + : detail.updated_at.toISOString() + : '', + lastGitSyncAt: detail.last_git_sync_at + ? typeof detail.last_git_sync_at === 'string' + ? detail.last_git_sync_at + : detail.last_git_sync_at.toISOString() + : detail.updated_at + ? typeof detail.updated_at === 'string' + ? detail.updated_at + : detail.updated_at.toISOString() + : '', }; } @@ -1126,7 +719,7 @@ class AgentsService extends Service { this.ctx.throw(400, 'Agent 名称不能为空'); } - const { Agent, AgentFile, AgentSkill } = this.app.model; + const { Agent, AgentSkill } = this.app.model; const row = await Agent.findOne({ where: { name, @@ -1145,10 +738,6 @@ class AgentsService extends Service { transaction, } ); - await AgentFile.destroy({ - where: { agent_id: row.id }, - transaction, - }); await AgentSkill.destroy({ where: { agent_id: row.id }, transaction, @@ -1156,9 +745,9 @@ class AgentsService extends Service { }); try { - this.removeDirectory( - path.join(this.getAgentMarketConfig().storageDir, `${row.name}/${row.content_hash}`) - ); + const storageDir = this.getAgentMarketConfig().storageDir; + this.removeDirectory(path.join(storageDir, `${row.name}/${row.content_hash}`)); + this.removeDirectory(path.join(storageDir, 'git_repos', row.name)); } catch (error) { this.ctx.logger.warn(`[agents] 清理资源目录失败: ${error.message}`); } @@ -1168,6 +757,712 @@ class AgentsService extends Service { deleted: true, }; } + // 解析 GitLab 访问 Token,优先从 env.json、配置及环境变量读取 + resolveGitlabToken() { + let envConfig = {}; + try { + const envPath = path.resolve(__dirname, '../../env.json'); + if (fs.existsSync(envPath)) { + // 清理 require 缓存,确保用户修改 env.json 后无需重启服务即可生效 + delete require.cache[require.resolve(envPath)]; + envConfig = require(envPath); + } + } catch (error) { + envConfig = {}; + } + const agentConfig = this.getAgentMarketConfig?.() || {}; + const token = + envConfig.gitlabToken || + envConfig.GITLAB_TOKEN || + agentConfig.gitlabToken || + this.app.config.skills?.gitlabToken || + process.env.GITLAB_TOKEN || + ''; + return String(token).trim(); + } + + // 解析 GitLab 域名白名单 + resolveGitlabHostWhitelist() { + const agentConfig = this.getAgentMarketConfig?.() || {}; + const list = agentConfig.gitlabHostWhitelist || this.app.config.skills?.gitlabHostWhitelist; + if (!Array.isArray(list)) return ['gitlab.prod.dtstack.cn']; + return list + .map((item) => + String(item || '') + .trim() + .toLowerCase() + ) + .filter(Boolean); + } + + // 从远端 URL 中提取主机名 + extractHostFromRemote(remoteUrl = '') { + const raw = String(remoteUrl || '').trim(); + if (!raw) return ''; + const httpMatch = raw.match(/^https?:\/\/([^/@:]+)(?::\d+)?(?:\/|$)/i); + if (httpMatch) return httpMatch[1].toLowerCase(); + const sshMatch = raw.match(/^git@([^:]+):/i); + if (sshMatch) return sshMatch[1].toLowerCase(); + return ''; + } + + // 获取 Git 命令执行认证前缀参数 + getGitAuthArgs(remoteUrl = '') { + const host = this.extractHostFromRemote(remoteUrl); + if (!host) { + return []; + } + const whitelist = this.resolveGitlabHostWhitelist(); + // 校验域名白名单 + if (whitelist.length > 0 && !whitelist.includes(host)) { + return []; + } + const token = this.resolveGitlabToken(); + if (!token) { + return []; + } + const basicToken = Buffer.from(`oauth2:${token}`).toString('base64'); + return ['-c', `http.extraHeader=Authorization: Basic ${basicToken}`]; + } + + // 脱敏错误信息中的 Authorization Header 与 Token,防止敏感凭据外泄 + sanitizeErrorMessage(raw = '') { + if (!raw) return ''; + return String(raw) + .replace( + /Authorization:\s*Basic\s+[a-zA-Z0-9+/=]+/gi, + 'Authorization: Basic [REDACTED]' + ) + .replace(/oauth2:[^@\s"']+/gi, 'oauth2:[REDACTED]') + .replace(/(https?:\/\/)([^:@\s]+):([^@\s]+)@/gi, '$1$2:[REDACTED]@'); + } + + // 异步执行 Git 命令,包含超时保护与敏感凭证脱敏 + async runGitCommand(args = [], options = {}) { + const { cwd, env, timeout = DEFAULT_GIT_TIMEOUT_MS } = options; + // 彻底清空交互提示与凭据弹窗环境变量,防止在 VSCode/Electron 等环境下唤起外部 askpass 脚本导致挂起 + const safeEnv = { + ...process.env, + ...env, + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', + SSH_ASKPASS: '', + }; + // 强制禁用交互式提示与系统凭证助手,并开启安全重定向跟踪 + const defaultArgs = [ + '-c', + 'core.askPass=', + '-c', + 'credential.helper=', + '-c', + 'http.followRedirects=true', + ]; + try { + return await execFileAsync('git', [...defaultArgs, ...args], { + cwd, + env: safeEnv, + timeout, + maxBuffer: 10 * 1024 * 1024, + }); + } catch (error) { + // 对错误信息与 stderr 进行敏感信息脱敏 + const sanitizedMessage = this.sanitizeErrorMessage(error.message); + const sanitizedStderr = this.sanitizeErrorMessage(error.stderr?.toString() || ''); + const sanitizedStdout = this.sanitizeErrorMessage(error.stdout?.toString() || ''); + const cleanError = new Error(sanitizedMessage); + cleanError.stderr = sanitizedStderr; + cleanError.stdout = sanitizedStdout; + cleanError.code = error.code; + throw cleanError; + } + } + + // 格式化 Git 错误信息,去除冗余的进度和重定向日志,返回简洁明确且已脱敏的错误提示 + formatGitCloneError(err, targetBranch = 'master') { + const rawStderr = err?.stderr ? err.stderr.toString() : ''; + const rawMsg = this.sanitizeErrorMessage(rawStderr || err?.message || String(err || '')); + + // 远端分支不存在 + if ( + rawMsg.includes('远程分支') || + rawMsg.includes('Remote branch') || + rawMsg.includes('not found in upstream origin') || + rawMsg.includes('did not match any file(s) known to git') + ) { + return `未在远端仓库找到分支「${targetBranch}」,请在设置中检查分支名称(如 master 或 main)`; + } + + // Git 认证与权限问题 + if ( + rawMsg.includes('could not read Username') || + rawMsg.includes('Authentication failed') || + rawMsg.includes('Permission denied') || + rawMsg.includes('terminal prompts disabled') || + rawMsg.includes('Access denied') || + rawMsg.includes('鉴权失败') + ) { + return 'Git 认证失败,请检查 env.json 或环境变量中是否配置了有效的 gitlabToken'; + } + + // 远端仓库不存在或无权限 + if ( + rawMsg.includes('The project you were looking for could not be found') || + (rawMsg.includes('repository') && rawMsg.includes('not found')) || + rawMsg.includes('仓库未找到') + ) { + return '未找到远程仓库,请检查仓库地址是否正确或是否有权限访问'; + } + + // 网络与连接问题 + if ( + rawMsg.includes('Could not resolve host') || + rawMsg.includes('Failed to connect') || + rawMsg.includes('Connection timed out') || + rawMsg.includes('Network is unreachable') || + rawMsg.includes('unable to access') + ) { + return '连接远程仓库失败,请检查网络连接或仓库地址'; + } + + // 未知错误时提取核心报错行,过滤掉进度和重定向日志 + const lines = rawMsg + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => { + if (!line) return false; + if (/^(正克隆到|Cloning into)/i.test(line)) return false; + if (/^(警告:重定向到|warning:\s*redirecting)/i.test(line)) return false; + return true; + }); + + // 优先提取包含 fatal 或 error 的关键错误行 + const fatalLine = lines.find((line) => /(?:fatal|error|致命错误|错误)[::]/i.test(line)); + if (fatalLine) { + return fatalLine.replace(/^(?:fatal|error|致命错误|错误)[::]\s*/i, '').trim(); + } + + // 无显式 fatal 标识时取最后一行有效输出 + if (lines.length > 0) { + return lines[lines.length - 1]; + } + + return '未知错误,请检查 Git 配置或查看服务端日志'; + } + + // 解析与规范化 Git 仓库地址,兼容从 GitLab 网页端复制的 tree/blob 路径,并解析仓库名与分支 + normalizeGitSource(rawUrl = '', rawBranch = 'master') { + const trimmed = String(rawUrl || '').trim(); + if (!trimmed) { + this.ctx.throw(400, '缺少 Git 仓库地址'); + } + + let cleanUrl = trimmed.replace(/#.*$/, '').replace(/\?.*$/, '').replace(/\/+$/, ''); + let targetBranch = String(rawBranch || 'master').trim() || 'master'; + + // 兼容 GitLab 网页端复制的 URL,如 http://gitlab.xxx.cn/group/project/-/tree/branch_name,支持带斜杠的多级分支名 + const treeMatch = cleanUrl.match(/^(https?:\/\/[^/]+\/.+?)(?:\/-)?\/(?:tree|blob)\/(.+)$/i); + if (treeMatch) { + cleanUrl = treeMatch[1].replace(/\/+$/, ''); + // 若用户未显式指定非 master 分支,优先采用 URL 中解析出的分支(去除首尾斜杠) + if (treeMatch[2] && (!rawBranch || rawBranch === 'master')) { + targetBranch = treeMatch[2].replace(/^\/+|\/+$/g, ''); + } + } + + // 提取仓库名(移除 .git 后缀) + const repoName = + cleanUrl + .split('/') + .pop() + .replace(/\.git$/i, '') || ''; + + // 严格前置校验仓库名格式,防止路径遍历或非法目录访问 + if (!repoName || !AGENT_NAME_PATTERN.test(repoName)) { + this.ctx.throw( + 400, + `非法的 Git 仓库名称「${repoName || cleanUrl}」,必须符合 kebab-case 规范` + ); + } + + // 自动规范化 HTTP/HTTPS 协议仓库地址,确保以 .git 结尾,避免 GitLab 301 重定向导致丢弃 Authorization 请求头 + if (/^https?:\/\//i.test(cleanUrl) && !cleanUrl.endsWith('.git')) { + cleanUrl = `${cleanUrl}.git`; + } + + // 校验分支名格式合法性,防止非法参数注入 + if (!GIT_BRANCH_PATTERN.test(targetBranch)) { + this.ctx.throw(400, `非法的分支名称: ${targetBranch}`); + } + + return { + cleanGitUrl: cleanUrl, + targetBranch, + repoName, + }; + } + + // 从 Git 仓库导入或更新 Agent + async importAgentFromGit(gitUrl, gitBranch = 'master', category = null) { + await this.ensureStorageReady(); + + const { cleanGitUrl, targetBranch, repoName } = this.normalizeGitSource(gitUrl, gitBranch); + + // 并发同步锁:若当前仓库正在同步中,阻止并发执行以避免 index.lock 冲突 + if (activeSyncRepos.has(repoName)) { + this.ctx.throw(409, `Agent「${repoName}」正在同步中,请稍后再试`); + } + activeSyncRepos.add(repoName); + + try { + const storageDir = this.getAgentMarketConfig().storageDir; + const reposDir = path.join(storageDir, 'git_repos'); + const targetDir = path.join(reposDir, repoName); + + fs.mkdirSync(reposDir, { recursive: true }); + + // 配置 Git 执行环境变量与认证参数,避免服务器因缺少终端或无权限时挂起 + const gitEnv = { + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', + SSH_ASKPASS: '', + GIT_SSH_COMMAND: 'ssh -o StrictHostKeyChecking=no', + }; + const authArgs = this.getGitAuthArgs(cleanGitUrl); + + // 1. 同步远端代码 + if (fs.existsSync(targetDir)) { + this.ctx.logger.info( + `[agents] Fetching ${cleanGitUrl}#${targetBranch} in ${targetDir}` + ); + try { + // 确保 remote url 与当前传入的 cleanGitUrl 保持一致,防止用户修改仓库地址后拉取旧地址 + try { + await this.runGitCommand(['remote', 'set-url', 'origin', cleanGitUrl], { + cwd: targetDir, + env: gitEnv, + }); + } catch (remoteErr) { + this.ctx.logger.warn(`[agents] 更新 remote url 失败: ${remoteErr.message}`); + } + + // 显式拉取指定分支并保持 depth 1,兼容同分支更新与跨分支切换 + await this.runGitCommand( + [...authArgs, 'fetch', '--depth', '1', 'origin', '--', targetBranch], + { + cwd: targetDir, + env: gitEnv, + } + ); + await this.runGitCommand(['reset', '--hard', 'FETCH_HEAD'], { + cwd: targetDir, + env: gitEnv, + }); + // 清理工作区未跟踪文件,防止脏文件污染 skills 扫描 + await this.runGitCommand(['clean', '-fd'], { + cwd: targetDir, + env: gitEnv, + }); + } catch (err) { + this.ctx.logger.warn( + `[agents] Git fetch 失败,尝试重新克隆: ${this.sanitizeErrorMessage( + err.message + )}` + ); + fs.rmSync(targetDir, { recursive: true, force: true }); + try { + await this.runGitCommand( + [ + ...authArgs, + 'clone', + '--depth', + '1', + '--branch', + targetBranch, + '--', + cleanGitUrl, + repoName, + ], + { cwd: reposDir, env: gitEnv } + ); + } catch (cloneErr) { + const errMsg = this.formatGitCloneError(cloneErr, targetBranch); + this.ctx.throw(400, `Git Clone 失败: ${errMsg}`); + } + } + } else { + this.ctx.logger.info( + `[agents] Cloning ${cleanGitUrl}#${targetBranch} to ${targetDir}` + ); + try { + await this.runGitCommand( + [ + ...authArgs, + 'clone', + '--depth', + '1', + '--branch', + targetBranch, + '--', + cleanGitUrl, + repoName, + ], + { cwd: reposDir, env: gitEnv } + ); + } catch (err) { + const errMsg = this.formatGitCloneError(err, targetBranch); + this.ctx.throw(400, `Git Clone 失败: ${errMsg}`); + } + } + + // 2. 解析 Manifests + const codexManifestPath = path.join(targetDir, '.codex-plugin/plugin.json'); + const claudeManifestPath = path.join(targetDir, '.claude-plugin/plugin.json'); + + if (!fs.existsSync(codexManifestPath)) { + this.ctx.throw(400, '仓库根目录缺少 .codex-plugin/plugin.json'); + } + if (!fs.existsSync(claudeManifestPath)) { + this.ctx.throw(400, '仓库根目录缺少 .claude-plugin/plugin.json'); + } + + const manifest = this.parseCodexPluginJson(fs.readFileSync(codexManifestPath, 'utf8')); + const claudeManifest = this.parseClaudePluginJson( + fs.readFileSync(claudeManifestPath, 'utf8') + ); + + const validated = this.validateCodexManifest(manifest); + const validatedClaude = this.validateClaudeManifest(claudeManifest); + + if (validated.name !== repoName || validatedClaude.name !== repoName) { + this.ctx.throw( + 400, + `Manifest name (${validated.name}) 必须与 Git 仓库名 (${repoName}) 保持一致` + ); + } + // 双向校验版本:两者核心版本必须完全一致 + const vCodex = String(validated.version || '') + .split('+')[0] + .trim(); + const vClaude = String(validatedClaude.version || '') + .split('+')[0] + .trim(); + if (vCodex !== vClaude) { + this.ctx.throw(400, '两个 plugin manifest 的 version 必须一致'); + } + + // 3. 计算最新提交 Hash + let contentHash; + try { + const { stdout } = await this.runGitCommand(['rev-parse', 'HEAD'], { + cwd: targetDir, + }); + contentHash = stdout.trim(); + } catch (e) { + this.ctx.throw(500, `解析 Git HEAD 提交哈希失败: ${e.message}`); + } + + // 4. 解析 Logo 资源 + let logo = null; + const LOGO_ALLOWED = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.webp']; + const logoPaths = validated.logoRef + ? [validated.logoRef] + : [ + ...LOGO_ALLOWED.map((name) => `assets/${name}`), + ...LOGO_ALLOWED.map((name) => `.codex-plugin/assets/${name}`), + ]; + + for (const relativeLogoPath of logoPaths) { + const absoluteLogoPath = path.join(targetDir, relativeLogoPath); + if (fs.existsSync(absoluteLogoPath)) { + const logoBuffer = fs.readFileSync(absoluteLogoPath); + const logoName = path.basename(relativeLogoPath); + const mimeType = mime.lookup(logoName) || 'application/octet-stream'; + logo = { + path: `${validated.name}/${contentHash}/${relativeLogoPath}`, + mimeType, + size: logoBuffer.length, + hash: crypto.createHash('sha256').update(logoBuffer).digest('hex'), + buffer: logoBuffer, + }; + break; + } else if (validated.logoRef) { + this.ctx.throw(400, `Logo 文件不存在: ./${relativeLogoPath}`); + } + } + + // 5. 数据库持久化 + const { Agent, AgentSkill } = this.app.model; + const existing = await Agent.findOne({ where: { name: validated.name } }); + + let agentId = existing ? existing.id : null; + + // 计算分类:优先使用导入指定的分类,其次保留已有分类或使用 manifest 解析的分类,兜底为工程效率 + const targetCategory = + category && isValidSkillCategory(category) + ? category + : existing?.category || validated.category || '工程效率'; + + const now = new Date(); + const isContentChanged = !existing || existing.content_hash !== contentHash; + + const agentPayload = { + name: validated.name, + display_name: validated.displayName, + version: validated.version, + description: validated.description, + profile: validated.longDescription, + author_name: validated.authorName, + category: targetCategory, + tags: JSON.stringify(validated.keywords || []), + prompts: JSON.stringify( + (validated.defaultPrompt || []).map((prompt, index) => ({ + title: `开场问题 ${index + 1}`, + prompt, + })) + ), + capabilities: JSON.stringify(validated.capabilities || []), + logo_path: logo ? logo.path : '', + logo_mime_type: logo ? logo.mimeType : '', + logo_size: logo ? logo.size : 0, + logo_hash: logo ? logo.hash : '', + content_hash: contentHash, + is_delete: 0, + git_url: cleanGitUrl, + git_branch: targetBranch, + last_git_refresh_at: now, + last_git_sync_at: isContentChanged + ? now + : existing?.last_git_sync_at || existing?.updated_at || now, + }; + + // 5. 静态资源与 ZIP 归档缓存(前置打包,确保归档就绪后再落库,保障原子性) + if (logo) { + const absolutePath = path.join(storageDir, logo.path); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, logo.buffer); + } + + const archiveDir = path.join(storageDir, validated.name, contentHash); + fs.mkdirSync(archiveDir, { recursive: true }); + const archivePath = path.join(archiveDir, `${validated.name}.zip`); + + // 异步执行 git archive 原生打包 + await this.runGitCommand( + [ + 'archive', + '--format=zip', + `--prefix=${validated.name}/`, + 'HEAD', + '-o', + archivePath, + ], + { cwd: targetDir } + ); + + // 6. 数据库持久化(归档已就绪,安全提交事务) + await this.app.model.transaction(async (transaction) => { + if (!existing) { + const created = await Agent.create(agentPayload, { transaction }); + agentId = created.id; + } else { + await Agent.update(agentPayload, { where: { id: existing.id }, transaction }); + } + + // 提取 skills 目录下的 SKILL.md + const agentSkills = []; + const skillsDir = path.join(targetDir, validated.skills || 'skills'); + if (fs.existsSync(skillsDir)) { + const items = fs.readdirSync(skillsDir); + for (const item of items) { + const skillItemDir = path.join(skillsDir, item); + if (!fs.statSync(skillItemDir).isDirectory()) continue; + // 兼容大小写 SKILL.md 与 skill.md + const skillFiles = fs.readdirSync(skillItemDir); + const skillMdFile = skillFiles.find((f) => f.toLowerCase() === 'skill.md'); + if (skillMdFile) { + const skillMdPath = path.join(skillItemDir, skillMdFile); + let skillName = extractSkillMdName( + fs.readFileSync(skillMdPath, 'utf8') + ).trim(); + if (!skillName) skillName = item; + if (skillName && !agentSkills.includes(skillName)) { + agentSkills.push(skillName); + } + } + } + } + + await AgentSkill.destroy({ where: { agent_id: agentId }, transaction }); + if (agentSkills.length > 0) { + await AgentSkill.bulkCreate( + agentSkills.map((skillSlug) => ({ + agent_id: agentId, + skill_slug: skillSlug, + })), + { transaction } + ); + } + }); + + // 清理旧版本的归档目录 + if (existing && existing.content_hash && existing.content_hash !== contentHash) { + this.removeDirectory(path.join(storageDir, validated.name, existing.content_hash)); + } + + return { + id: agentId, + name: validated.name, + version: validated.version, + updated: !!existing, + contentHash, + isContentChanged, + }; + } finally { + activeSyncRepos.delete(repoName); + } + } + + // 单独同步指定名称的 Agent + async syncGitAgentByName(name) { + if (!name) { + this.ctx.throw(400, '缺少 Agent 名称'); + } + const agent = await this.app.model.Agent.findOne({ + where: { + name, + is_delete: 0, + }, + }); + if (!agent) { + this.ctx.throw(404, `未找到 Agent: ${name}`); + } + if (!agent.git_url) { + this.ctx.throw(400, `Agent ${name} 未配置 Git 仓库地址`); + } + this.ctx.logger.info(`[agents] 单独同步 Agent ${agent.name} 来自 ${agent.git_url}`); + const result = await this.importAgentFromGit( + agent.git_url, + agent.git_branch || 'master', + agent.category + ); + return { + name: agent.name, + success: true, + version: result.version, + contentHash: result.contentHash, + isContentChanged: result.isContentChanged, + }; + } + + // 全量同步所有配置了 Git 仓库的 Agent + async syncAllGitAgents() { + const agents = await this.app.model.Agent.findAll({ + where: { + is_delete: 0, + git_url: { [this.app.Sequelize.Op.ne]: null }, + }, + }); + + // 过滤掉空字符串地址 + const validAgents = agents.filter((agent) => agent.git_url && String(agent.git_url).trim()); + + const results = []; + for (const agent of validAgents) { + try { + this.ctx.logger.info(`[agents] 正在同步 Agent ${agent.name} 来自 ${agent.git_url}`); + const result = await this.importAgentFromGit( + agent.git_url, + agent.git_branch, + agent.category + ); + results.push({ + name: agent.name, + success: true, + version: result.version, + contentHash: result.contentHash, + isContentChanged: result.isContentChanged, + }); + } catch (error) { + this.ctx.logger.error(`[agents] 同步 Agent ${agent.name} 失败: ${error.message}`); + results.push({ name: agent.name, success: false, error: error.message }); + } + } + return results; + } + + // 更新 Agent 的 Git 仓库配置,支持可选立即同步 + async updateAgentGitConfig(params = {}) { + const { name, gitUrl, gitBranch, category, syncNow } = params; + if (!name) { + this.ctx.throw(400, '缺少 Agent 名称'); + } + const agent = await this.app.model.Agent.findOne({ + where: { + name, + is_delete: 0, + }, + }); + if (!agent) { + this.ctx.throw(404, `未找到 Agent: ${name}`); + } + const updates = {}; + if (gitUrl !== undefined) { + const rawUrl = String(gitUrl || '').trim(); + if (rawUrl) { + const { cleanGitUrl: normalizedUrl, targetBranch: normalizedBranch } = + this.normalizeGitSource(rawUrl, gitBranch || agent.git_branch); + updates.git_url = normalizedUrl; + if (gitBranch === undefined) { + updates.git_branch = normalizedBranch; + } + } else { + updates.git_url = ''; + } + } + if (gitBranch !== undefined) { + const targetBranch = String(gitBranch || 'master').trim(); + if (!GIT_BRANCH_PATTERN.test(targetBranch)) { + this.ctx.throw(400, `非法的分支名称: ${targetBranch}`); + } + updates.git_branch = targetBranch; + } + if (category && isValidSkillCategory(category)) { + updates.category = category; + } + await agent.update(updates); + + if (syncNow) { + if (!agent.git_url) { + this.ctx.throw(400, '请先填写 GitLab 仓库地址'); + } + this.ctx.logger.info( + `[agents] 更新配置并立即同步 Agent ${agent.name} 来自 ${agent.git_url}#${agent.git_branch}` + ); + const syncResult = await this.importAgentFromGit( + agent.git_url, + agent.git_branch || 'master', + agent.category + ); + return { + name: agent.name, + gitUrl: agent.git_url, + gitBranch: agent.git_branch, + category: agent.category, + synced: true, + version: syncResult.version, + contentHash: syncResult.contentHash, + isContentChanged: syncResult.isContentChanged, + }; + } + + return { + name: agent.name, + gitUrl: agent.git_url, + gitBranch: agent.git_branch, + category: agent.category, + synced: false, + }; + } } module.exports = AgentsService; diff --git a/app/service/skills.js b/app/service/skills.js index 5c4913b7..a7916705 100644 --- a/app/service/skills.js +++ b/app/service/skills.js @@ -2441,9 +2441,21 @@ class SkillsService extends Service { return env; } + // 解析 GitLab 访问 Token,优先支持 env.json 配置 resolveGitlabToken() { - const token = this.getSkillsConfig().gitlabToken; - return String(token || '').trim(); + let envConfig = {}; + try { + envConfig = require('../../env.json'); + } catch (error) { + envConfig = {}; + } + const token = + envConfig.gitlabToken || + envConfig.GITLAB_TOKEN || + this.getSkillsConfig().gitlabToken || + process.env.GITLAB_TOKEN || + ''; + return String(token).trim(); } resolveGitlabHostWhitelist() { diff --git a/app/web/ typings/global.d.ts b/app/web/ typings/global.d.ts index 38624113..7c2db741 100644 --- a/app/web/ typings/global.d.ts +++ b/app/web/ typings/global.d.ts @@ -19,3 +19,15 @@ declare module '*.svg' { const value: string; export default value; } +declare module '*.jpg' { + const value: string; + export default value; +} +declare module '*.jpeg' { + const value: string; + export default value; +} +declare module '*.webp' { + const value: string; + export default value; +} diff --git a/app/web/api/url.ts b/app/web/api/url.ts index 685ba0b5..94d10cda 100644 --- a/app/web/api/url.ts +++ b/app/web/api/url.ts @@ -421,12 +421,24 @@ export default { method: 'get', url: '/api/agents/download', }, - importAgentFile: { - method: 'postForm', - url: '/api/agents/import-file', - }, deleteAgent: { method: 'post', url: '/api/agents/delete', }, + importAgentFromGit: { + url: '/api/agents/import-git', + method: 'post', + }, + syncAllGitAgents: { + url: '/api/agents/sync-git', + method: 'post', + }, + syncSingleGitAgent: { + url: '/api/agents/sync-git', + method: 'post', + }, + updateAgentGitConfig: { + url: '/api/agents/update-git-config', + method: 'post', + }, }; diff --git a/app/web/asset/images/default_agent.jpg b/app/web/asset/images/default_agent.jpg new file mode 100644 index 00000000..d0786ced Binary files /dev/null and b/app/web/asset/images/default_agent.jpg differ diff --git a/app/web/pages/agents/components/AgentGitOpsModal.tsx b/app/web/pages/agents/components/AgentGitOpsModal.tsx new file mode 100644 index 00000000..c3b00f5b --- /dev/null +++ b/app/web/pages/agents/components/AgentGitOpsModal.tsx @@ -0,0 +1,155 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Input, Modal, Select, Space, Typography } from 'antd'; + +const { Text } = Typography; +const { Option } = Select; + +const categoryOptions = [ + '通用', + '前端', + '后端', + '数据与AI', + '运维与系统', + '工程效率', + '安全', + '其他', +]; + +interface AgentGitOpsModalProps { + visible: boolean; + title: string; + description: string; + mode: 'import' | 'setting'; + loading?: boolean; + showSyncBtn?: boolean; + initialUrl?: string; + initialBranch?: string; + initialCategory?: string; + onCancel: () => void; + onOk: (data: { gitUrl: string; gitBranch: string; category: string }, sync?: boolean) => void; +} + +// 统一封装的 Agent Git 配置与导入弹窗组件 +export const AgentGitOpsModal: React.FC = ({ + visible, + title, + description, + mode, + loading = false, + showSyncBtn = true, + initialUrl = '', + initialBranch = 'master', + initialCategory = '工程效率', + onCancel, + onOk, +}) => { + const [gitUrl, setGitUrl] = useState(initialUrl); + const [gitBranch, setGitBranch] = useState(initialBranch); + const [category, setCategory] = useState(initialCategory); + + useEffect(() => { + if (visible) { + setGitUrl(initialUrl || ''); + setGitBranch(initialBranch || 'master'); + setCategory(initialCategory || '工程效率'); + } + }, [visible, initialUrl, initialBranch, initialCategory]); + + // 触发提交回调,支持携带是否立即同步标识 + const handleOk = (sync = false) => { + // 清洗用户输入的仓库地址与分支名称,移除尾部锚点和空格 + const cleanUrl = (gitUrl || '').trim().replace(/#.*$/, '').replace(/\/+$/, ''); + const cleanBranch = (gitBranch || '').trim() || 'master'; + onOk({ gitUrl: cleanUrl, gitBranch: cleanBranch, category }, sync); + }; + + // 根据模式和是否展示同步按钮动态生成弹窗底部操作栏 + const renderFooter = () => { + if (mode === 'import') return undefined; + if (!showSyncBtn) { + return [ + , + , + ]; + } + return [ + , + , + , + ]; + }; + + const footer = renderFooter(); + + return ( + handleOk(mode === 'import')} + footer={footer} + > + + {description} +
+
GitLab 仓库地址:
+ setGitUrl(e.target.value.trim())} + /> +
+
+
Git 分支:
+ setGitBranch(e.target.value.trim())} + /> +
+
+
分类:
+ +
+
+
+ ); +}; diff --git a/app/web/pages/agents/detail/AgentDetailContent.tsx b/app/web/pages/agents/detail/AgentDetailContent.tsx index 708ecf5e..ea9fd62e 100644 --- a/app/web/pages/agents/detail/AgentDetailContent.tsx +++ b/app/web/pages/agents/detail/AgentDetailContent.tsx @@ -4,13 +4,18 @@ import { CopyOutlined, DownloadOutlined, QuestionCircleOutlined, + SettingOutlined, + SyncOutlined, } from '@ant-design/icons'; -import { Button, Card, Empty, message, Spin, Tag, Typography } from 'antd'; +import { Button, Card, Empty, message, Spin, Tag, Tooltip, Typography } from 'antd'; +import moment from 'moment'; import { API } from '@/api'; import { copyToClipboard } from '@/utils/copyUtils'; import { safeOpenUrl } from '@/utils/safeOpenUrl'; +import defaultAgentLogo from '../../../asset/images/default_agent.jpg'; import { buildAgentDetailCodexPrompt, buildCodexNewThreadUrl } from '../codex-button-utils'; +import { AgentGitOpsModal } from '../components/AgentGitOpsModal'; import type { AgentDetail, AgentItem, AgentSkill } from '../types'; import './style.scss'; @@ -29,16 +34,14 @@ const RelatedAgentCard: React.FC<{ onClick={() => history.push(`/page/agents/${item.name}`)} >
- {item.logoUrl ? ( - {item.displayName} { - event.currentTarget.style.visibility = 'hidden'; - }} - /> - ) : null} + {item.displayName} { + event.currentTarget.src = defaultAgentLogo; + }} + />
{item.displayName} {item.description || '暂无描述'} @@ -56,6 +59,104 @@ const AgentDetailContent: React.FC = ({ name, history } const [loading, setLoading] = useState(true); const [detail, setDetail] = useState(null); const [related, setRelated] = useState([]); + const [syncing, setSyncing] = useState(false); + const [settingVisible, setSettingVisible] = useState(false); + const [settingLoading, setSettingLoading] = useState(false); + // 最近一次向 Git 远端检查刷新的时间 + const lastRefreshTime = useMemo(() => { + const timeVal = detail?.lastGitRefreshAt || detail?.updatedAt; + if (!timeVal) return '-'; + const m = moment(timeVal); + return m.isValid() ? m.format('YYYY-MM-DD HH:mm:ss') : '-'; + }, [detail?.lastGitRefreshAt, detail?.updatedAt]); + + // 最近一次 Git 代码发生变动并同步生效的时间 + const lastSyncTime = useMemo(() => { + const timeVal = detail?.lastGitSyncAt || detail?.updatedAt; + if (!timeVal) return '-'; + const m = moment(timeVal); + return m.isValid() ? m.format('YYYY-MM-DD HH:mm:ss') : '-'; + }, [detail?.lastGitSyncAt, detail?.updatedAt]); + + const handleOpenSetting = () => { + if (!detail) return; + setSettingVisible(true); + }; + + const handleSaveSetting = async ( + data: { gitUrl: string; gitBranch: string; category: string }, + syncNow = false + ) => { + if (!detail) return; + if (!data.gitUrl) { + message.error('请填写 GitLab 仓库地址'); + return; + } + + setSettingLoading(true); + try { + const response = await API.updateAgentGitConfig({ + name: detail.name, + gitUrl: data.gitUrl, + gitBranch: data.gitBranch || 'master', + category: data.category, + syncNow, + }); + + if (!response.success) { + message.error(response.msg || '保存失败'); + return; + } + + // 根据是否立即同步以及远端代码是否变动区分提示文案 + if (syncNow) { + if (response.data?.isContentChanged) { + message.success('配置已保存,并成功同步最新代码'); + } else { + message.success('配置已保存,当前已是最新版本(无代码变动)'); + } + } else { + message.success('配置保存成功'); + } + setSettingVisible(false); + const detailRes = await API.getAgentDetail({ name }); + if (detailRes.success) { + setDetail(detailRes.data as AgentDetail); + } + } catch (error) { + message.error(syncNow ? '同步失败,请检查 URL、分支或服务端 Git 权限' : '保存配置失败'); + console.error('更新 Agent Git 配置失败:', error); + } finally { + setSettingLoading(false); + } + }; + + const handleSyncSingleAgent = async () => { + if (!detail) return; + setSyncing(true); + try { + const res = await API.syncSingleGitAgent({ name: detail.name }); + if (!res.success) { + message.error(res.msg || '同步失败'); + return; + } + // 根据远端代码是否发生变更给出差异化反馈 + if (res.data?.isContentChanged) { + message.success('同步成功,已更新至最新代码'); + } else { + message.info('当前已是最新版本,无代码变动'); + } + // 重新拉取最新详情刷新展示 + const detailRes = await API.getAgentDetail({ name }); + if (detailRes.success) { + setDetail(detailRes.data as AgentDetail); + } + } catch (error) { + message.error('同步失败,请检查服务端 Git 访问权限'); + } finally { + setSyncing(false); + } + }; useEffect(() => { let cancelled = false; @@ -144,16 +245,14 @@ const AgentDetailContent: React.FC = ({ name, history }
- {detail.logoUrl ? ( - {detail.displayName} { - event.currentTarget.style.display = 'none'; - }} - /> - ) : null} + {detail.displayName} { + event.currentTarget.src = defaultAgentLogo; + }} + />
{detail.displayName}
@@ -219,7 +318,7 @@ const AgentDetailContent: React.FC = ({ name, history }
- 快速使用 + 快速使用(Codex) {introBlocks.openingQuestions.length} 个 @@ -359,6 +458,12 @@ const AgentDetailContent: React.FC = ({ name, history } + + +
+
+ + 最近刷新时间 + + + + + {lastRefreshTime || '-'} +
+
+ + 最近同步代码时间 + + + + + {lastSyncTime} +
+
+ + ) : ( +
+ 尚未配置 Git 仓库地址 + +
+ )} +
+
{related.length > 0 ? ( @@ -392,8 +563,21 @@ const AgentDetailContent: React.FC = ({ name, history }
+ + setSettingVisible(false)} + onOk={(data, sync) => handleSaveSetting(data, sync || false)} + />
); }; - export default AgentDetailContent; diff --git a/app/web/pages/agents/detail/style.scss b/app/web/pages/agents/detail/style.scss index 89277456..627bf5f8 100644 --- a/app/web/pages/agents/detail/style.scss +++ b/app/web/pages/agents/detail/style.scss @@ -20,10 +20,12 @@ .agent-hero, .agent-section-card, .agent-side-actions, + .agent-side-sync, .agent-side-related { border-radius: 20px; border: 1px solid #EDF0F5; } + .agent-side-sync, .agent-side-related { margin-top: 20px; .ant-card-head { @@ -38,6 +40,75 @@ padding: 16px; } } + .agent-side-sync { + .ant-card-extra { + padding: 12px 0; + .ant-btn.agent-side-sync-setting-btn { + width: 28px; + height: 28px; + min-width: 28px; + padding: 0 !important; + border: none !important; + border-radius: 6px !important; + box-shadow: none !important; + background: transparent; + color: #94A3B8; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + .anticon { + font-size: 15px; + line-height: 1; + } + &:hover, + &:focus { + color: #1E293B; + background: #F1F5F9 !important; + } + } + } + .agent-sync-meta-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 14px; + padding-top: 12px; + border-top: 1px dashed #E2E8F0; + } + .agent-sync-meta-item { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + line-height: 1.5; + .meta-label { + display: inline-flex; + align-items: center; + gap: 4px; + color: #64748B; + .meta-tip-icon { + color: #94A3B8; + cursor: pointer; + font-size: 12px; + transition: color 0.2s ease; + &:hover { + color: #475569; + } + } + } + .meta-value { + color: #334155; + font-family: SFMono-Regular, Consolas, monospace; + font-weight: 500; + } + } + .agent-sync-empty { + padding: 8px 0; + font-size: 12px; + text-align: center; + } + } .related-agent-list { display: flex; flex-direction: column; diff --git a/app/web/pages/agents/index.tsx b/app/web/pages/agents/index.tsx index 040b5ee6..dcb6b6f5 100644 --- a/app/web/pages/agents/index.tsx +++ b/app/web/pages/agents/index.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { DeleteOutlined, ImportOutlined, SearchOutlined, UploadOutlined } from '@ant-design/icons'; +import { DeleteOutlined, ImportOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons'; import { Button, Card, @@ -12,14 +12,16 @@ import { Space, Spin, Tag, + Tooltip, Typography, - Upload, } from 'antd'; import debounce from 'lodash/debounce'; import { API } from '@/api'; import helpIcon from '@/asset/images/help-icon.png'; import config from '../../../../env.json'; +import defaultAgentLogo from '../../asset/images/default_agent.jpg'; +import { AgentGitOpsModal } from './components/AgentGitOpsModal'; import type { AgentItem, AgentListResponse } from './types'; import './style.scss'; @@ -60,11 +62,44 @@ const AgentMarket: React.FC = ({ history }) => { const [keywordInput, setKeywordInput] = useState(''); const [importVisible, setImportVisible] = useState(false); const [importing, setImporting] = useState(false); - const [uploadFiles, setUploadFiles] = useState([]); + const [syncingAll, setSyncingAll] = useState(false); + const [syncingAgentName, setSyncingAgentName] = useState(null); const [deleteEnabled, setDeleteEnabled] = useState(false); const queryRef = useRef(query); queryRef.current = query; + // 单独同步指定 Agent 的 Git 仓库代码 + const handleSyncSingleAgent = async ( + agent: AgentItem, + event?: React.MouseEvent + ) => { + event?.stopPropagation(); + if (!agent.gitUrl) { + message.warning('该 Agent 尚未配置 Git 仓库地址'); + return; + } + setSyncingAgentName(agent.name); + try { + const response = await API.syncSingleGitAgent({ name: agent.name }); + if (!response.success) { + message.error(response.msg || '同步失败'); + return; + } + const displayName = agent.displayName || agent.name; + // 根据远端代码是否发生变更给出差异化反馈 + if (response.data?.isContentChanged) { + message.success(`已成功同步【${displayName}】的最新代码`); + } else { + message.info(`【${displayName}】当前已是最新版本,无代码变动`); + } + fetchAgents(queryRef.current); + } catch (error: any) { + message.error(error.message || '同步异常'); + } finally { + setSyncingAgentName(null); + } + }; + const fetchAgents = useCallback(async (nextQuery) => { setLoading(true); try { @@ -75,7 +110,13 @@ const AgentMarket: React.FC = ({ history }) => { } const data = response.data as AgentListResponse; - setAgents(data.list || []); + // 列表按名称首字母升序排序,支持中文拼音与英文不区分大小写 + const sortedList = (data.list || []).slice().sort((a, b) => { + const nameA = a.displayName || a.name || ''; + const nameB = b.displayName || b.name || ''; + return nameA.localeCompare(nameB, 'zh-CN', { sensitivity: 'base', numeric: true }); + }); + setAgents(sortedList); setCategories(data.categories?.length ? data.categories : FALLBACK_CATEGORIES); setTotal(data.total || 0); } catch (error) { @@ -138,54 +179,52 @@ const AgentMarket: React.FC = ({ history }) => { }); }; - const submitImport = async (confirmOverwrite = false) => { - const targetFile = uploadFiles[0]?.originFileObj; - if (!targetFile) { - message.error('请先选择 .zip 文件'); + const submitImport = async (data: { gitUrl: string; gitBranch: string; category: string }) => { + if (!data.gitUrl) { + message.error('请填写 GitLab 仓库地址'); return; } setImporting(true); try { - const response = await API.importAgentFile({ - file: targetFile, - confirmOverwrite: confirmOverwrite ? 'true' : 'false', + const response = await API.importAgentFromGit({ + gitUrl: data.gitUrl, + gitBranch: data.gitBranch || 'master', + category: data.category, }); if (!response.success) { message.error(response.msg || '导入失败'); return; } - if (response.data?.requiresConfirm) { - Modal.confirm({ - title: `检测到同名 Agent「${response.data.name}」`, - content: `当前版本 ${response.data.currentVersion},导入版本 ${response.data.incomingVersion},是否覆盖`, - okText: '覆盖导入', - cancelText: '取消', - onOk: () => submitImport(true), - }); - return; - } - - if (response.data?.unchanged) { - message.info('内容未变化'); - } else if (response.data?.updated) { - message.success('更新成功'); - } else { - message.success('导入成功'); - } - + message.success('导入成功'); setImportVisible(false); - setUploadFiles([]); fetchAgents(queryRef.current); } catch (error) { - message.error('导入失败,请检查 ZIP 文件'); + message.error('导入失败,请检查 URL、分支或服务端 Git 权限'); console.error('导入 Agent 失败:', error); } finally { setImporting(false); } }; + const handleSyncGit = async () => { + setSyncingAll(true); + try { + const response = await API.syncAllGitAgents({}); + if (!response.success) { + message.error(response.msg || '同步失败'); + return; + } + message.success('同步触发成功'); + fetchAgents(queryRef.current); + } catch (error: any) { + message.error(error?.message || '同步失败'); + } finally { + setSyncingAll(false); + } + }; + const categoryOptions = useMemo( () => (categories.length ? categories : FALLBACK_CATEGORIES), [categories] @@ -204,13 +243,18 @@ const AgentMarket: React.FC = ({ history }) => {

Agent 市场

发现并导入适用于不同研发场景的 Agent

- + + + +
{config.agentHelpDocUrl ? ( @@ -270,16 +314,14 @@ const AgentMarket: React.FC = ({ history }) => { >
- {agent.logoUrl ? ( - {agent.displayName} { - event.currentTarget.style.display = 'none'; - }} - /> - ) : null} + {agent.displayName} { + event.currentTarget.src = defaultAgentLogo; + }} + />
{agent.displayName}
@@ -291,15 +333,45 @@ const AgentMarket: React.FC = ({ history }) => {
- {deleteEnabled ? ( -
) : null}
@@ -338,34 +410,15 @@ const AgentMarket: React.FC = ({ history }) => {
) : null} - { - if (importing) return; - setImportVisible(false); - setUploadFiles([]); - }} - onOk={() => submitImport(false)} - > - - - 仅支持导入单个 Agent ZIP。Agent 信息会从包内 `.codex-plugin/plugin.json` - 自动解析。 - - false} - onChange={(info) => setUploadFiles(info.fileList.slice(-1))} - > - - - - + title="导入 Agent (GitLab)" + description="请输入独立 Agent 的 GitLab 仓库地址。系统会自动拉取代码,并在后台完成入库和解析。" + mode="import" + loading={importing} + onCancel={() => setImportVisible(false)} + onOk={(data) => submitImport(data)} + />
); }; diff --git a/app/web/pages/agents/style.scss b/app/web/pages/agents/style.scss index c3f5f558..4df9df16 100644 --- a/app/web/pages/agents/style.scss +++ b/app/web/pages/agents/style.scss @@ -88,6 +88,19 @@ align-items: flex-start; justify-content: space-between; gap: 12px; + .ant-btn { + border: none; + border-color: transparent; + box-shadow: none; + background: transparent; + &:hover, + &:focus, + &:active { + border: none; + border-color: transparent; + box-shadow: none; + } + } } .agent-card-brand { display: flex; diff --git a/app/web/pages/agents/types.ts b/app/web/pages/agents/types.ts index 798fcf64..e61282db 100644 --- a/app/web/pages/agents/types.ts +++ b/app/web/pages/agents/types.ts @@ -30,6 +30,10 @@ export interface AgentItem { updatedAt: string; logoUrl: string; skillCount?: number; + gitUrl?: string; + gitBranch?: string; + lastGitRefreshAt?: string; + lastGitSyncAt?: string; } export interface AgentListResponse { diff --git a/app/web/scss/reset.scss b/app/web/scss/reset.scss index c48c3466..e148ad22 100644 --- a/app/web/scss/reset.scss +++ b/app/web/scss/reset.scss @@ -127,7 +127,9 @@ iframe { // input .ant-input, -.ant-select-selection { +.ant-input-affix-wrapper, +.ant-select-selection, +.ant-select-selector { border-radius: 2px; } // input-search @@ -167,7 +169,20 @@ iframe { } } -// select - 选择框 +// select - 选择框高度统一对齐标准 32px +.ant-select-single:not(.ant-select-customize-input) { + .ant-select-selector { + height: 32px; + .ant-select-selection-search-input { + height: 30px; + } + .ant-select-selection-item, + .ant-select-selection-placeholder { + line-height: 30px; + } + } +} + .ant-select-dropdown { .ant-select-item { font-size: 12px; @@ -202,6 +217,18 @@ iframe { background: #FF7875; border-color: #FF7875; } + &.ant-btn-text { + border: none; + border-color: transparent; + box-shadow: none; + &:hover, + &:focus, + &:active { + border: none; + border-color: transparent; + box-shadow: none; + } + } } // ant-menu - 菜单 @@ -213,6 +240,22 @@ iframe { background-color: transparent; } +// 规范 affix-wrapper(带 allowClear 或 prefix 的单行 input)的高度与内边距,对齐标准 input +.ant-input-affix-wrapper:not(.ant-input-affix-wrapper-textarea-with-clear-btn) { + padding: 4px 11px; + height: 32px; + display: inline-flex; + align-items: center; + > input.ant-input { + padding: 0; + border: none; + height: 100%; + line-height: normal; + border-radius: 0; + box-shadow: none; + } +} + .ant-input-affix-wrapper::before { display: inline-block; } diff --git a/config/config.default.js b/config/config.default.js index 592bf6a8..3071bc41 100644 --- a/config/config.default.js +++ b/config/config.default.js @@ -33,7 +33,7 @@ module.exports = (app) => { }, { prefix: '/agent-market/', - dir: path.resolve(app.baseDir, '../agent-market/'), + dir: path.join(app.baseDir, 'app/public'), maxAge: 0, // maxAge 缓存,默认 1 年 buffer: false, // 不读进内存,改文件立即生效 dynamic: true, // 实时读取文件,支持热更新 @@ -52,11 +52,9 @@ module.exports = (app) => { }; exports.agentMarket = { storageDir: process.env.AGENT_MARKET_STORAGE_DIR || '/data/doraemon/agent-market', - maxZipSize: 50 * 1024 * 1024, - maxExtractedSize: 200 * 1024 * 1024, - maxFileCount: 500, - maxSingleFileSize: 20 * 1024 * 1024, - maxImageSize: 5 * 1024 * 1024, + gitlabToken: process.env.GITLAB_TOKEN || '', + gitlabHostWhitelist: ['gitlab.prod.dtstack.cn'], + autoSyncInterval: process.env.AGENT_MARKET_AUTO_SYNC_INTERVAL || '5m', }; exports.middleware = ['access']; diff --git a/docs/docsify/imgs/agent-detail.png b/docs/docsify/imgs/agent-detail.png index 178c910b..6b1a1353 100644 Binary files a/docs/docsify/imgs/agent-detail.png and b/docs/docsify/imgs/agent-detail.png differ diff --git a/docs/docsify/imgs/agent-list.png b/docs/docsify/imgs/agent-list.png index 6014cbce..0064ca94 100644 Binary files a/docs/docsify/imgs/agent-list.png and b/docs/docsify/imgs/agent-list.png differ diff --git a/docs/docsify/zh-cn/configuration/envConfig.md b/docs/docsify/zh-cn/configuration/envConfig.md index c53119df..228f8e27 100644 --- a/docs/docsify/zh-cn/configuration/envConfig.md +++ b/docs/docsify/zh-cn/configuration/envConfig.md @@ -116,6 +116,19 @@ Agent 市场首页右下角帮助文档入口跳转链接 } ``` +## gitlabToken + +- 类型:String +- 默认值:'' + +用于 Agent 市场从私有 GitLab 仓库拉取和同步 Agent 插件代码的 Access Token。亦可直接配置环境变量 `GITLAB_TOKEN`。服务端在向白名单域名(默认包含 `gitlab.prod.dtstack.cn`)克隆时会自动注入认证信息。 + +```json +{ + "gitlabToken": "glpat-xxxxxxxxxxxxxxxxxxxx" +} +``` + ## mysql - 类型:Object diff --git a/docs/docsify/zh-cn/guide/agent-market.md b/docs/docsify/zh-cn/guide/agent-market.md index 469232bf..f1681a4e 100644 --- a/docs/docsify/zh-cn/guide/agent-market.md +++ b/docs/docsify/zh-cn/guide/agent-market.md @@ -1,52 +1,59 @@ # Agent 市场 -Agent 市场是 Doraemon 提供的 Agent Registry 与展示页能力,**以 Codex 与 Claude Code 双宿主原生 Plugin 的形式统一分发**和管理面向复杂研发场景的 Agent 插件包。 +Agent 市场是 Doraemon 提供的 Agent Registry 与管理中心,**以 Codex 与 Claude Code 双宿主原生 Plugin 的形式统一分发**和管理面向复杂研发场景的 Agent 插件。 -Doraemon 并不定义私有的 Agent 运行时协议,而是全面兼容主流 AI 编码环境(OpenAI Codex 与 Anthropic Claude Code)的官方 Plugin 规范: +Doraemon 并不定义私有的 Agent 运行时协议,而是全面兼容主流 AI 编码环境(OpenAI Codex 与 Anthropic Claude Code)的官方 Plugin 规范,并采用 **GitOps 驱动** 的模式进行版本追踪与全生命周期管理: +- **GitOps 驱动的插件源**:每个 Agent 的代码与元数据均托管在独立的 GitLab 仓库中,由 Doraemon 服务端通过 Git 操作进行拉取、解析、版本校验与归档分发。 - **以 Plugin 形式分发**:上架的每个 Agent 均是一个标准的双宿主 Plugin,包含了各自所需的 Manifest 清单与自包含的 Skills 依赖。 -- **Web 展示与检索**:在页面中浏览 Agent 列表、查看详情、浏览关联 Skills 并获得相关 Agent 推荐。 -- **安装包存储与分发**:保存导入的 Agent 插件 ZIP 包,充当企业私有 Plugin Registry,提供一键复制的终端安装命令及原始 ZIP 下载。 +- **轻量高效的归档存储**:服务端利用 Git 提交 Hash 进行精确版本追踪,并通过 `git archive` 实时生成标准的插件 ZIP 包,充当企业私有 Plugin Registry,提供终端一键安装脚本及离线 ZIP 下载。 +- **Web 展示与全生命周期管理**:在 Web 页面中检索 Agent 列表、查看详情与关联 Skills,支持一键全量同步或单个 Agent 增量同步,支持在页面直接配置远端 Git 仓库。 - **宿主快捷使用**:在 Agent 详情页中,通过“快捷使用”入口直接唤起 Codex 新会话,并把选中的开场问题和上下文预填到输入框中。 > [!NOTE] > Doraemon 不负责在服务端在线执行 Agent。Agent 的实际运行由使用方本地的 Codex 或 Claude Code 等宿主环境完成,Doraemon 作为私有 Marketplace 负责其展示与分发。 -## 入口 +## 入口与页面结构 - Agent 列表页:`/page/agents` - Agent 详情页:`/page/agents/` ### 列表页功能 -列表页支持: +![agent-list.png](../../imgs/agent-list.png) + +列表页提供便捷的检索、筛选与 GitOps 快捷操作: -- 按名称、描述、作者或关键词过滤检索 Agent -- 按分类(如“工程效率”、“通用”等)筛选 -- 浏览 Agent 卡片(展示 Logo、显示名称、作者、分类、简介、关键词标签及版本号) +- **搜索与分类过滤**:支持按名称、描述、作者或关键词过滤检索 Agent,支持按分类(如“工程效率”、“通用”等)快速筛选。 +- **全量同步(GitOps)**:页面右上角提供【全量同步】按钮,可一键向远端 GitLab 批量拉取所有已配置仓库的 Agent 最新代码。 +- **导入 Agent**:点击【导入 Agent】按钮打开 GitOps 导入弹窗,输入 GitLab 仓库地址、分支及分类即可自动导入入库。 +- **Agent 卡片信息**: + - 展示 Agent Logo(若未配置或加载失败,系统自动回退显示默认头像)、显示名称、作者、版本号、所属分类、功能描述及关键词 Tags。 + - 展示包含技能数量(`X 个技能`)。 + - 卡片右上角操作:针对已配置 Git 仓库的 Agent,提供【从 Git 同步最新代码】按钮,支持单独对该 Agent 触发即时代码同步。 ### 详情页结构 -详情页采用统一的单页纵向流式布局与侧边栏结构: +![agent-detail.png](../../imgs/agent-detail.png) + +详情页采用单页纵向流式布局与侧边栏结构: -- **顶部 Hero 区域**:展示 Agent Logo、显示名称、作者、版本、分类、功能列表(逗号分隔)及关键词 Tags +- **顶部 Hero 区域**:展示 Agent Logo、显示名称、作者、版本、分类、能力项列表(capabilities,逗号分隔)及关键词 Tags。 - **左侧主体区域**: - - **功能概览**:Agent 的核心用途说明 - - **Agent 简介与快速使用**:正文长描述段落,以及开场问题列表(支持一键在 Codex 中“快捷使用”) - - **Skills 模块**:解析自包内 `skills/` 的关联技能网格卡片,展示技能名称、描述以及 Skills Hub 收录状态(收录项可点击跳转) + - **功能概览**:展示 Agent 的核心定位与用途说明(对应 manifest 的 `description`)。 + - **Agent 简介与快速使用(Codex)**:正文长描述段落(`longDescription`),以及开场问题列表(支持一键在 Codex 中“快捷使用”)。 + - **Skills 模块**:自动解析自包内 `skills/` 的关联技能网格卡片,展示技能名称、描述以及 Skills Hub 收录状态(收录项可点击直接跳转至 Skills Hub 详情页)。 - **右侧边栏区域**: - - **安装命令**:终端样式的安装脚本调用命令,支持一键复制 - - **下载 Agent ZIP**:支持直接下载当前版本的原始插件 ZIP 包 - - **相关 Agent**:基于共同技能重叠度自动计算并推荐相关 Agent + - **安装命令**:终端样式的安装脚本调用命令,支持一键复制。 + - **下载 Agent ZIP**:支持直接下载由服务端 `git archive` 打包生成的当前版本原始插件 ZIP 包。 + - **Git 仓库设置**(GitOps 管理): + - 提供【从 Git 同步最新代码】快捷同步按钮。 + - 展示【最近刷新时间】(最近一次向 GitLab 发起检查确认是否有新提交的时间)与【最近同步代码时间】(最近一次拉取到新提交并入库生效的时间)。 + - 点击右上角设置图标(齿轮)可打开弹窗调整当前 Agent 的 GitLab 仓库地址、拉取分支及分类。 + - **相关 Agent 推荐**:基于共同技能重叠度自动计算并智能推荐相关 Agent。 ## 快速使用 -### 浏览和查看详情 - -进入 Agent 市场后,可以先在列表页按关键字或分类检索,点击卡片进入详情页查看完整说明。 - -![agent-list.png](../../imgs/agent-list.png) - ### 复制安装命令与 Plugin 分发机制 Agent 市场中的 Agent 是**以 Codex 和 Claude Code 原生 Plugin 的形式进行分发与安装的**。详情页右侧会根据当前站点地址自动生成一键安装命令: @@ -59,19 +66,44 @@ curl -fsSL http://127.0.0.1:7001/agent-market/install.sh | bash -s -- bugfix-age 当使用者在终端执行该命令时,安装脚本 `install.sh` 会自动完成以下操作: -1. **同步本地 Marketplace 目录**:将最新的 marketplace 归档快照下载并解压至本地 `~/.agents/agent-market`。 -2. **探测本地宿主 CLI**:自动探测当前环境中是否存在 `codex`(包括独立 CLI 及 macOS 桌面应用内置路径)与 `claude`(Claude Code CLI)。 -3. **注册 Marketplace**:自动将本地目录注册为宿主的本地 Marketplace: - - Codex:`codex plugin marketplace add ~/.agents/agent-market` - - Claude Code:`claude plugin marketplace add ~/.agents/agent-market` -4. **作为 Plugin 原生安装**: +1. **下载源码归档**:从 Doraemon 服务端接口 `/api/agents/download?name=` 下载当前版本的源码 ZIP 归档,并解压至临时目录。 +2. **部署至本地 Marketplace 目录**:将解压后的 Agent 移动至本地集中目录 `~/.agents/agent-market/agents/`。 +3. **动态维护本地双端 Marketplace**: + - 自动在 `~/.agents/agent-market/.codex-plugin/marketplace.json` 与 `~/.agents/agent-market/.claude-plugin/marketplace.json` 中增量维护或更新该 Agent 插件条目。 +4. **探测本地宿主 CLI**: + - 自动探测系统环境中是否存在 `codex`(包括 PATH 中的独立 CLI,以及 macOS 桌面应用内置路径 `/Applications/ChatGPT.app/Contents/Resources/codex`)。 + - 自动探测系统环境中是否存在 `claude`(Claude Code CLI)。 +5. **注册本地 Marketplace 源**: + - Codex:自动执行 `codex plugin marketplace add ~/.agents/agent-market` + - Claude Code:自动执行 `claude plugin marketplace add ~/.agents/agent-market` +6. **作为 Plugin 原生安装**: - 在 Codex 中安装:`codex plugin add @agent-market` - - 在 Claude Code 中安装:`claude plugin install @agent-market` -5. **执行环境预检**:运行 Agent 内部自带的 `setup.sh` 脚本,探测环境变量与基础工具依赖并输出检查结果。 + - 在 Claude Code 中安装:`claude plugin install @agent-market [--yes]`(自动探测 `--yes` 支持度,非交互终端下自动跳过确认) +7. **执行环境预检**: + - 若 Agent 根目录下自带 `setup.sh` 脚本,安装器会自动运行该脚本探测运行时依赖的工具与环境变量,并在终端输出清晰的【环境检查结论】报告。 +8. **输出调用指引**: + - 自动解析 Manifest 中声明的入口 Skill,并在安装末尾打印出双端对应的调用命令,如: + - Codex:`$bugfix-workflow` + - Claude Code:`/bugfix-agent:bugfix-workflow` + +#### 环境变量自定义 + +执行安装脚本时,支持通过环境变量自定义参数: + +```bash +# 覆盖服务端地址(默认根据当前站点生成) +export AGENT_MARKET_BASE_URL="http://172.16.100.225:7001/agent-market" + +# 覆盖本地 Marketplace 存放目录(默认: ~/.agents/agent-market) +export AGENT_MARKET_LOCAL_DIR="$HOME/.agents/agent-market" + +# 覆盖 Marketplace 名称(默认: agent-market) +export AGENT_MARKET_NAME="agent-market" +``` #### 手动 Plugin 安装方式 -由于分发产物遵循官方 Plugin 规范,除了使用上述一键安装脚本外,也可以直接使用宿主原生 Plugin 命令手动管理: +由于分发产物遵循官方 Plugin 规范,使用者也可以直接通过宿主原生命令手动管理: ```bash # 1. 注册本地 agent-market 源(初次使用) @@ -85,143 +117,143 @@ claude plugin install bugfix-agent@agent-market ### 插件卸载(Uninstall) -如果不再需要某个 Agent 插件,可以通过 Agent 市场提供的一键卸载脚本进行清理,也可以直接使用宿主的原生 Plugin 命令移除。 +如果不再需要某个 Agent 插件,推荐直接使用对应宿主的原生 Plugin 命令从本地环境中移除: -#### 一键卸载命令 - -卸载脚本与安装脚本位于同一静态服务路径下,执行格式如下: +#### 1. 使用宿主官方命令卸载 ```bash -curl -fsSL http://127.0.0.1:7001/agent-market/uninstall.sh | bash -s -- bugfix-agent -``` - -#### 一键卸载的底层行为 +# 从 Codex 中移除插件 +codex plugin remove @agent-market -执行卸载脚本时,脚本会自动识别安装痕迹并完成以下清理操作: - -1. **宿主插件卸载**: - - Claude Code:自动执行 `claude plugin uninstall @agent-market` - - Codex:自动执行 `codex plugin remove @agent-market` -2. **清理插件 Cache**:自动清理本地残余的插件缓存目录(`~/.claude/plugins/cache/agent-market/` 与 `~/.codex/plugins/cache/agent-market/`),避免遗留孤儿文件 -3. **保留 Marketplace 注册**:默认保留本地 `agent-market` 源注册,以便继续使用其他 Agent +# 从 Claude Code 中卸载插件 +claude plugin uninstall @agent-market +``` -#### 手动 Plugin 卸载方式 +#### 2. (可选)清理本地源码与缓存 -也可以直接使用各宿主官方 CLI 进行手动卸载: +若希望彻底清理本地由安装脚本同步的 Agent 源码快照与宿主缓存,可手动执行: ```bash -# 1. 从各宿主中卸载指定的 Agent 插件 -codex plugin remove bugfix-agent@agent-market -claude plugin uninstall bugfix-agent@agent-market +# 移除本地 marketplace 中的该 Agent 源码目录 +rm -rf ~/.agents/agent-market/agents/ -# 2. (可选)若不再使用该市场源,可彻底移除 marketplace 注册 -codex plugin marketplace remove agent-market -claude plugin marketplace remove agent-market +# 移除宿主本地插件缓存 +rm -rf ~/.codex/plugins/cache/agent-market/ +rm -rf ~/.claude/plugins/cache/agent-market/ ``` +> [!TIP] +> 默认情况下无需移除 `agent-market` 的源注册,以便后续安装或更新其他 Agent。若彻底不再使用本平台的所有插件,可执行 `codex plugin marketplace remove agent-market` 或 `claude plugin marketplace remove agent-market`。 + ### 下载 Agent ZIP -详情页右侧提供“下载 Agent ZIP”按钮,支持直接下载当前 Agent 的原始插件包。 +详情页右侧提供“下载 Agent ZIP”按钮,支持直接下载当前 Agent 由服务端通过 `git archive` 原生生成的完整插件 ZIP 归档。 适合以下场景: - -- 本地离线检查 Agent 插件包内部结构与代码 -- 参考已有插件的组织方式制作新的 Agent 插件 -- 离线环境下分发与手动安装 - -如果页面提示原始 ZIP 不存在,说明该数据为历史导入数据且尚未补齐 ZIP 存档。重新上传一次同内容 ZIP 即可恢复下载能力。 +- 本地离线检查 Agent 插件包内部结构与实现代码。 +- 离线环境下分发与手动解压安装。 +- 作为模板参考已有插件的规范组织方式。 ### 直接在 Codex 中快捷使用 -在 Agent 详情页的“快速使用”区域中,每个开场问题右侧均提供“快捷使用”按钮。 +在 Agent 详情页的“快速使用(Codex)”区域中,每个开场问题右侧均提供“快捷使用”按钮。 点击后会: +1. 打开本地 Codex 并创建新任务会话。 +2. 自动带入当前 Agent 的上下文信息。 +3. 自动把选中的推荐指令预填到输入框中。 -1. 打开本地 Codex 并创建新任务会话 -2. 自动带入当前 Agent 的上下文信息 -3. 自动把选中的开场问题预填到输入框中 +## GitOps 导入与代码同步 -该入口适合首次体验 Agent,或者直接从推荐提问开始快速启动任务。 +Doraemon 采用 GitOps 模式管理 Agent 插件,实现代码集中在 GitLab 托管、平台自动追踪同步。 -## 详情页说明 +### 1. 导入 Agent(GitLab) -![agent-detail.png](../../imgs/agent-detail.png) - -### 顶部 Hero 区域 - -展示 Agent 的核心标识与分类属性: - -- **Logo**:展示 Agent 图标;如果未提供图片,系统会自动回退展示显示名称首字母的占位图 -- **基本信息**:显示名称、作者、版本号以及所属分类 -- **功能列表**:读取自配置的能力项(`capabilities`),以纯文本逗号分隔展示(如 `Interactive, Read, Write`) -- **关键词**:读取自插件的标签列表(`keywords`),以彩色 Tag 形式直观呈现(如 `bugfix`、`zentao`、`gitlab` 等) - -### 功能概览 - -位于主体区域顶部,展示 Agent 的简短定位与用途描述(对应插件配置中的 `description`),便于使用者第一眼了解该 Agent 解决的核心问题。 - -### Agent 简介与快速使用 - -包含两部分正文: - -- **Agent 简介**:展示 Agent 的详细长描述正文(对应配置中的 `interface.longDescription`),展开说明其工作流程、角色契约与交付机制 -- **快速使用(开场问题)**:展示推荐的交互提问与典型触发指令(对应配置中的 `interface.defaultPrompt`,最多 3 条),每条问题均支持点击右侧“快捷使用”按钮调起 Codex - -### Skills 模块 - -系统在导入 Agent 插件包时,会自动扫描包内 `skills/` 目录下的所有 `SKILL.md`,提取关联的技能名称与描述,并以卡片网格形式展示: +在列表页点击【导入 Agent】,弹窗支持配置以下信息: -- 每个卡片展示技能名称及简要描述 -- 系统会自动与 Doraemon 的 **Skills Hub** 进行比对: - - 若已在 Skills Hub 中收录:卡片可悬浮并支持点击,将在新标签页中打开对应 Skill 的详情页 - - 若尚未收录:卡片右上角标记为 `暂未收录` +- **GitLab 仓库地址**: + - 支持标准的 Git 仓库 URL,例如:`http://gitlab.prod.dtstack.cn/ai-agents/bugfix-agent.git` + - **智能 URL 兼容**:支持直接粘贴 GitLab 网页端的查看分支或文件链接(例如 `http://gitlab.prod.dtstack.cn/ai-agents/bugfix-agent/-/tree/dev`),系统会自动剥离后缀提取纯净仓库地址,并智能提取链接中的目标分支。 +- **Git 分支**:拉取的目标分支,默认为 `master`。 +- **分类**:设置所属分类(通用、前端、后端、数据与AI、运维与系统、工程效率、安全、其他)。 -### 相关 Agent 推荐 +### 2. 代码同步机制 -相关 Agent 展示在右侧边栏下方,并非由人工配置,而是系统根据 Agent 之间包含的共同技能依赖重叠度进行定向查询与智能推荐(最多展示 3 个)。 - -点击推荐卡片可直接跳转至对应 Agent 的详情页。如果当前暂无技能重叠的 Agent,则显示“暂无相关 Agent”。 - -## 导入与更新规则 - -Agent 市场通过上传单个 Agent ZIP 插件包完成导入与版本发布。 - -### 导入校验规则 - -上传 ZIP 时,服务端会进行严格的安全与格式校验: - -1. **目录层级**:ZIP 顶层必须且只能包含一个 Agent 根目录 -2. **双 Manifest 齐备**: - - 必须同时包含根目录 `.codex-plugin/plugin.json`(Codex 规范及 Doraemon 元数据来源) - - 必须同时包含根目录 `.claude-plugin/plugin.json`(Claude Code 规范) -3. **名称与版本一致性**: - - 两个 manifest 中的 `name` 必须一致,且必须与 ZIP 顶层根目录名完全相同 - - `name` 必须符合命名规范(由小写字母、数字和中划线组成) - - `version` 必须是合法的 SemVer 语义化版本;若 Claude manifest 中声明了 `version`,两者必须保持一致 -4. **必填元数据**: - - `.codex-plugin/plugin.json` 中的 `description` 与 `author.name` 不能为空 - - `interface.displayName` 不能为空 -5. **路径真实性**: - - `.codex-plugin/plugin.json` 中的 `skills` 字段必须为 `./` 开头的相对路径,且对应目录在 ZIP 内必须真实存在 - - `.claude-plugin/plugin.json` 中的 `agents` 数组声明的角色文件在 ZIP 内必须真实存在 -6. **开场问题限制**: - - `interface.defaultPrompt` 最多支持 3 条,每条必须是 128 字符以内的纯文本 -7. **Logo 规范**: - - 支持通过 `interface.logo` 显式指定相对路径(如 `./assets/logo.png` 或 `./.codex-plugin/assets/logo.png`) - - 若未显式指定,系统会自动尝试读取 `assets/logo.{png,jpg,jpeg,webp}` 或 `.codex-plugin/assets/logo.{png,jpg,jpeg,webp}` - - 仅支持 `PNG`、`JPEG`、`WebP` 格式图片;若未提供 Logo,详情页前端将自动回退首字母占位展示 +> [!NOTE] +> **自动更新周期说明**: +> 系统默认配置了**后台定时自动同步任务(默认每 5 分钟轮询一次)**。远端 GitLab 仓库合入新代码后,后台会在周期触发时自动拉取更新并使新版本生效。若希望代码变动即时生效,可使用页面按钮或 CI/CD API 手动触发即时同步。 + +代码同步支持以下触发方式: + +- **后台定时自动同步**:服务端默认每 5 分钟自动执行一次全量检查与增量同步(可通过服务端配置 `autoSyncInterval` 调整周期或关闭)。 +- **单个 Agent 手动同步**:在列表页卡片右上角点击【从 Git 同步最新代码】,或在详情页侧边栏点击【从 Git 同步最新代码】按钮。 +- **全量批量手动同步**:在列表页顶部点击【全量同步】按钮,后台将按序批量同步所有配置了 Git 仓库的 Agent。 +- **配置修改并即时同步**:在详情页【Git 仓库设置】弹窗中修改分支或仓库地址后,可选择保存并立即同步。 +- **CI/CD 流水线自动触发(推荐)**:支持在 GitLab CI/CD 或自动化部署脚本中通过 API 触发指定 Agent 的即时同步: + ```bash + # 触发指定 Agent 同步 + curl -X POST http://127.0.0.1:7001/api/agents/sync-git \ + -H "Content-Type: application/json" \ + -d '{"name": "bugfix-agent"}' + ``` + +#### 服务端同步底层处理流程 + +1. **并发同步保护**:系统维护基于仓库名的并发锁(Active Sync Lock),防止多用户并发触发导致本地仓库产生 `index.lock` 竞争冲突。 +2. **高效克隆与拉取**: + - 首次导入时,在服务端存储目录(`storageDir/git_repos/`)执行 `git clone --depth 1 --branch ` 浅克隆。 + - 后续同步时,执行 `git fetch --all` 与 `git reset --hard origin/`。若遇到损坏或远端历史改写,会自动安全回退并重新浅克隆。 +3. **双 Manifest 契约校验**: + - 检查根目录下 `.codex-plugin/plugin.json` 与 `.claude-plugin/plugin.json` 是否齐全。 + - 校验 Manifest 中的 `name` 必须与 GitLab 仓库名(去除 `.git` 后缀)完全一致。 + - 校验两个 Manifest 中的 `version` 核心版本号必须完全一致。 + - 校验 `skills` 声明的目录及 Claude `agents` 引用的角色文件真实存在。 +4. **精确版本追踪(Content Hash)**: + - 执行 `git rev-parse HEAD` 获取当前分支最新提交的 Commit SHA 作为 `content_hash`。 + - 数据库分别维护: + - **最近刷新时间(last_git_refresh_at)**:每一次向 GitLab 发起探测和拉取的时间。 + - **最近同步代码时间(last_git_sync_at)**:仅当检测到新提交(Commit Hash 发生变化)并完成更新时刷新的时间。 +5. **归档生成与缓存轮转**: + - 使用原生命令 `git archive --format=zip --prefix=/ HEAD -o ` 实时打出标准 ZIP 归档文件,放置于缓存目录供下载及 `install.sh` 分发。 + - 自动清理旧提交 Hash 产生的历史归档目录,防止磁盘膨胀。 +6. **技能关联与 Logo 提取**: + - 自动扫描 `skills/` 目录下的所有 `SKILL.md`(兼容大小写文件名),同步至 `agent_skills` 关系表。 + - 提取并缓存 Logo 资源。 + +### 3. Git 仓库设置修改 + +在 Agent 详情页右侧边栏的【Git 仓库设置】卡片中,点击齿轮设置图标可打开配置弹窗: + +- 可以随时修改 GitLab 仓库地址、拉取分支及所属分类。 +- 支持在保存时选择是否立即执行代码同步。 + +## 插件开发与脚手架工具 + +为了降低双端原生 Plugin 的开发门槛,Doraemon 提供了快速脚手架脚本 `create-plugin.sh`,可一键生成规范工程。 + +### 一键创建 Plugin 骨架 + +在终端运行以下命令: -### 版本更新规则 +```bash +# 格式: curl -fsSL http://127.0.0.1:7001/agent-market/create-plugin.sh | bash -s -- [目标目录] +curl -fsSL http://127.0.0.1:7001/agent-market/create-plugin.sh | bash -s -- my-agent ./ +``` -当上传的 Agent 名称已在系统中存在时: +脚本将自动执行以下操作: +1. 规范化命名:自动将插件名转换为符合规范的 kebab-case 格式。 +2. 生成双 Manifest 模板:自动生成预置详细字段注释的 `.claude-plugin/plugin.json` 与 `.codex-plugin/plugin.json`。 +3. 生成示例角色:生成 Claude 角色(`agents/claude/my-agent-assistant.md`)与 Codex 角色(`agents/my-agent-assistant.toml`)。 +4. 生成示例技能:生成 `skills/demo/SKILL.md` 骨架。 +5. 生成 Hook 与 MCP 配置模板:生成 `hooks/claude-hooks.json`、`hooks/codex-hooks.json` 与 `.mcp.json`。 +6. 生成规范说明文档:生成规范的 `README.md` 与 `assets/` 图标目录。 -- **低版本禁止覆盖高版本**:系统会根据 SemVer 版本号进行校验,若上传版本低于数据库中现存版本将直接报错拒绝 -- **同版本/同内容重复上传**:不会重复写入结构化数据库记录,但会自动补齐或刷新当前内容哈希对应的原始 ZIP 存档 -- **新版本覆盖更新**:写入新的资源快照和原始 ZIP,更新数据库元数据与技能关联,并自动清理旧版本内容哈希目录下的历史静态资源,防止磁盘垃圾残留 +> [!TIP] +> 脚手架生成的 `plugin.json` 带有方便对照填写的注释(JSONC 格式)。在提交发布到 GitLab 前,请将注释移除转换为标准严格 JSON,并可通过 `claude plugin validate .` 进行格式验证。 -## Agent ZIP 目录约定 +## Agent 仓库目录约定 -Agent 插件包遵循统一的双宿主插件标准布局。以典型 Agent `bugfix-agent` 为例,标准的目录结构如下: +托管在 GitLab 上的 Agent 仓库遵循统一的双宿主插件标准布局。以典型 Agent `bugfix-agent` 为例,标准的目录结构如下: ```text bugfix-agent @@ -231,7 +263,7 @@ bugfix-agent │ └── plugin.json # Claude Code 插件 manifest ├── assets/ │ └── logo.png # Agent Logo 图标(可选) -├── skills/ # 包含的 Skills(一律随包快照分发,自包含) +├── skills/ # 包含的 Skills(随包快照分发,自包含) │ ├── bugfix-workflow/ # 核心入口工作流 Skill │ │ ├── SKILL.md │ │ ├── references/ @@ -246,22 +278,21 @@ bugfix-agent │ └── bugfix-reviewer.md # Claude Code 审校角色定义 ├── setup.sh # 安装前环境预检脚本(由 install.sh 调用) ├── README.md # Agent 使用说明 -├── MIGRATION.md # 版本迁移与快照同步记录 └── LICENSE # 开源或授权协议 ``` ### 目录与文件职责说明 -- **`.codex-plugin/plugin.json`**:插件核心清单文件,定义插件名称、版本、关联技能目录,并在 `interface` 节点中提供 Doraemon 页面展示所需的完整元数据 -- **`.claude-plugin/plugin.json`**:Claude Code 规范清单,声明插件名称并在 `agents` 中列出对应的 Claude 角色定义文件 -- **`skills/`**:包含 Agent 的入口工作流技能以及所依赖的所有技能。所有依赖技能一律作为快照打包进 `skills/` 随包分发,确保插件在离线或独立环境下自包含可用 -- **`agents/`**:定义具体的子角色(Subagents)。包括 Codex 格式(`*.toml`)与 Claude Code 格式(`claude/*.md`)的双宿主角色契约 -- **`setup.sh`**:安装前预检脚本。供 `install.sh` 在安装时执行环境探测,产出检查报告;它不是插件运行时钩子,加载插件时不会执行 -- **`assets/logo.png`**:插件展示图标,推荐 256x256 尺寸的正方形图片 +- **`.codex-plugin/plugin.json`**:插件核心清单文件,定义插件名称、版本、关联技能目录,并在 `interface` 节点中提供 Doraemon 页面展示所需的元数据。 +- **`.claude-plugin/plugin.json`**:Claude Code 规范清单,声明插件名称并在 `agents` 中列出对应的 Claude 角色定义文件。 +- **`skills/`**:包含 Agent 的入口工作流技能以及所依赖的所有技能。所有依赖技能作为快照存放在 `skills/` 随仓库统一分发,确保插件在离线或独立环境下自包含可用。 +- **`agents/`**:定义具体的子角色(Subagents)。包括 Codex 格式(`*.toml`)与 Claude Code 格式(`claude/*.md`)的双宿主角色契约。 +- **`setup.sh`**:安装前预检脚本。供 `install.sh` 在客户端安装时探测宿主机的基础工具与环境变量;它不是插件运行时钩子,仅在安装阶段执行。 +- **`assets/logo.png`**:插件展示图标,推荐 256x256 正方形图片(支持 PNG、JPEG、WebP)。 ## Manifest 规范要求 -插件包根目录下必须同时维护两份 Manifest 清单,严禁声明 `tools` 或 `model` 字段(角色能力完全通过 Skill 调用与角色契约表达)。 +仓库根目录下必须同时维护两份 Manifest 清单,严禁声明 `tools` 或 `model` 字段(角色能力完全通过 Skill 调用与角色契约表达)。 ### 1. `.codex-plugin/plugin.json` @@ -297,18 +328,18 @@ bugfix-agent | 字段路径 | 类型 | 是否必填 | 说明 | | :--- | :--- | :--- | :--- | -| `name` | string | 是 | 唯一标识,必须与 ZIP 根目录名一致 | -| `version` | string | 是 | 语义化版本号(SemVer) | +| `name` | string | 是 | 唯一标识,**必须与 GitLab 仓库名一致**(如 `bugfix-agent.git` 对应 `bugfix-agent`) | +| `version` | string | 是 | 语义化版本号(SemVer),与 Claude 清单版本一致 | | `description` | string | 是 | 简短描述,用于列表卡片和详情页“功能概览” | -| `author.name` | string | 是 | 作者或团队名称 | +| `author.name` | string | 是 | 作者或团队名称(亦兼容 `interface.developerName`) | | `keywords` | string[] | 否 | 关键词标签,用于搜索过滤与顶部 Hero 标签展示 | -| `skills` | string | 是 | 技能相对路径,必须为 `./skills/` 或包含技能的相对目录 | +| `skills` | string | 是 | 技能相对路径,必须为 `./` 开头(如 `./skills/`),仓库内必须真实存在 | | `interface.displayName` | string | 是 | 详情页与列表页主标题显示名称 | | `interface.longDescription` | string | 否 | 详情页“Agent 简介”展示的完整长描述,支持多段落 | | `interface.category` | string | 否 | 分类标识(如 `Coding` 映射为 `工程效率`) | | `interface.capabilities` | string[] / object[] | 否 | 能力项列表,在 Hero 区域作为功能项逗号分隔展示 | | `interface.defaultPrompt` | string[] | 否 | 开场问题列表,最多 3 条,每条 ≤ 128 字符,提供“快捷使用”入口 | -| `interface.logo` | string | 否 | Logo 相对路径,如 `./assets/logo.png` | +| `interface.logo` | string | 否 | Logo 相对路径,如 `./assets/logo.png`;缺省时自动探测 `assets/` 下图片 | ### 2. `.claude-plugin/plugin.json` @@ -329,8 +360,29 @@ bugfix-agent } ``` -- `name`:必须与 `.codex-plugin/plugin.json` 的 `name` 严格保持一致 -- `agents`:必须声明角色定义文件相对路径列表,引用的文件在包内必须真实存在 +- `name`:必须与 `.codex-plugin/plugin.json` 的 `name` 严格保持一致。 +- `agents`:必须声明角色定义文件相对路径列表,引用的文件在仓库内必须真实存在。 + +## 服务端配置说明 + +在服务端的 `config/config.default.js` 或根目录 `env.json` 中,可针对 Agent 市场进行如下配置: + +```javascript +// config/config.default.js +exports.agentMarket = { + // 仓库克隆与归档存储目录,可通过环境变量 AGENT_MARKET_STORAGE_DIR 自定义 + storageDir: process.env.AGENT_MARKET_STORAGE_DIR || '/data/doraemon/agent-market', + // 私有 GitLab 访问 Token,可通过环境变量 GITLAB_TOKEN 或 env.json 中的 gitlabToken 配置 + gitlabToken: process.env.GITLAB_TOKEN || '', + // 允许注入 Token 的 GitLab 域名白名单 + gitlabHostWhitelist: ['gitlab.prod.dtstack.cn'], + // 仓库自动轮询定时同步间隔,可通过环境变量 AGENT_MARKET_AUTO_SYNC_INTERVAL 自定义,传空或 '0' 表示关闭 + autoSyncInterval: process.env.AGENT_MARKET_AUTO_SYNC_INTERVAL || '5m', +}; +``` + +- **自动轮询同步**:`autoSyncInterval` 默认值为 `'5m'`(每 5 分钟执行一次全量轮询拉取)。当设置为 `''` 或 `'0'` 时可关闭定时轮询。 +- **凭证安全**:当配置了 `gitlabToken` 时,服务端仅在克隆/拉取属于 `gitlabHostWhitelist` 白名单内的仓库时才会注入 Basic Auth 认证。在执行 Git 操作发生错误时,系统会自动将命令输出和报错中的 Token 及 Authorization 字段进行脱敏,避免凭证泄漏到前端或日志中。 ## 与 Skills Hub 的关系 @@ -339,4 +391,4 @@ Agent 市场与 Skills Hub 是分层互补的能力体系: - **Skills Hub**:管理和收录原子技能(Skill),专注于单个技能的能力定义、文档、脚本及工具封装。 - **Agent 市场**:管理面向复杂业务场景的复合插件单元(Agent),将入口工作流与多个专业依赖技能有机组合,配合多角色契约形成可独立分发的工程交付单元。 -在 Agent 详情页中,系统会自动列出该 Agent 内置的所有技能,并实时联动 Skills Hub 标注收录状态,实现从整体解决方案到原子技能沉淀的双向贯通。 +在 Agent 详情页中,系统会自动列出该 Agent 包含的所有技能,并实时联动 Skills Hub 标注收录状态,实现从整体解决方案到原子技能沉淀的双向贯通。 diff --git a/docs/superpowers/plans/2026-08-11-agent-market.md b/docs/superpowers/plans/2026-08-11-agent-market.md deleted file mode 100644 index 0a5924ef..00000000 --- a/docs/superpowers/plans/2026-08-11-agent-market.md +++ /dev/null @@ -1,245 +0,0 @@ -# Agent Market Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 为 Doraemon 落地可导入 ZIP、可从数据库读取列表与详情、可流式返回资源文件的 Agent 市场第一版 - -**Architecture:** 复用现有 Skill 市场的路由、控制器和页面骨架,新增独立的 `agents + agent_files + agent_skills` 数据模型与 `agents` 服务。导入时将 ZIP 先解压到临时目录,校验并解析 `agent.yaml` 后,把结构化字段写入数据库、把 `assets/` 写入 `/data/doraemon/agent-market` 配置目录,再通过数据库索引对外提供列表、详情、相关推荐和图片流接口。 - -**Tech Stack:** Egg.js 2.x、Sequelize、React 16、Ant Design 4、Node.js test runner、SCSS - -## Global Constraints - -- 必须保留现有 Skill 市场实现,不重构 `skills_*` 表和页面 -- 一个 ZIP 只允许一个 Agent,且必须包含唯一顶层目录和根部 `agent.yaml` -- 图片二进制不能写入数据库,只能保存到 `config.agentMarket.storageDir` -- 列表、详情、图片接口都必须以数据库为正式数据源 -- 图片 URL 不能暴露服务器绝对路径,也不能返回 Base64 -- 导入失败时不能留下半写入的数据库记录或本次新增资源目录 -- 详情页只展示 `概览 / Agent 简介 / Agent 能力` 三个页签 -- “安装”“使用”按钮只弹 `message.info('安装')` / `message.info('使用')` -- Demo 图片按内容宽度 100% 展示,高度自适应,图片间距 16px -- 分类复用 Skills 分类选项,相关推荐只按依赖 Skills 交集计算 - ---- - -### Task 1: 建立 Agent 数据模型与后端测试骨架 - -**Files:** -- Create: `app/model/agent.js` -- Create: `app/model/agent_file.js` -- Create: `app/model/agent_skill.js` -- Create: `test/agent-market-service.test.js` -- Modify: `sql/doraemon.sql` - -**Interfaces:** -- Consumes: `app.Sequelize`、现有 Skills 分类常量 -- Produces: `app.model.Agent`、`app.model.AgentFile`、`app.model.AgentSkill` - -- [ ] **Step 1: 写后端红灯测试** - -```js -test('Agent 模型字段包含资源索引和内容快照字段', async () => { - const agent = require('../app/model/agent'); - assert.equal(typeof agent, 'function'); -}); -``` - -- [ ] **Step 2: 运行单测确认失败** - -Run: `node --test test/agent-market-service.test.js` -Expected: FAIL,提示 `Cannot find module '../app/model/agent'` - -- [ ] **Step 3: 最小实现三个模型和 SQL 表结构** - -```js -module.exports = (app) => { - const { INTEGER, STRING, TEXT, DATE, TINYINT } = app.Sequelize; - return app.model.define('agent', { - name: { type: STRING(100), allowNull: false, unique: true }, - // 其余字段按设计文档补齐 - }, { - tableName: 'agents', - createdAt: 'created_at', - updatedAt: 'updated_at', - }); -}; -``` - -- [ ] **Step 4: 重跑单测确认通过** - -Run: `node --test test/agent-market-service.test.js` -Expected: PASS - -- [ ] **Step 5: 提交当前阶段** - -```bash -git add app/model/agent.js app/model/agent_file.js app/model/agent_skill.js sql/doraemon.sql test/agent-market-service.test.js -git commit -m "feat: add agent market models" -``` - -### Task 2: 按 TDD 落地 Agent 导入、更新、删除和资源读取服务 - -**Files:** -- Create: `app/service/agents.js` -- Create: `app/controller/agents.js` -- Modify: `app/router.js` -- Modify: `config/config.default.js` -- Modify: `test/agent-market-service.test.js` - -**Interfaces:** -- Consumes: `ctx.request.files`、`app.model.Agent`、`app.model.AgentFile`、`app.model.AgentSkill` -- Produces: - - `ctx.service.agents.queryAgentList(params)` - - `ctx.service.agents.getAgentDetail(name)` - - `ctx.service.agents.getRelatedAgents(name, limit)` - - `ctx.service.agents.importAgentFile(params, file)` - - `ctx.service.agents.deleteAgent(params)` - - `ctx.service.agents.getAgentAssetStream(params)` - -- [ ] **Step 1: 为导入规则和资源读取写失败测试** - -```js -test('导入单 Agent ZIP 时会拆出结构化字段并保存资源相对路径', async () => { - const service = createAgentsService(); - await assert.rejects(() => service.importAgentFile({}, mockZipFile)); -}); -``` - -- [ ] **Step 2: 运行单测确认失败** - -Run: `node --test test/agent-market-service.test.js` -Expected: FAIL,提示 `service.importAgentFile is not a function` - -- [ ] **Step 3: 最小实现导入与查询主链路** - -```js -async importAgentFile(params, file) { - await this.ensureStorageReady(); - const parsed = await this.parseAgentZip(file); - return this.app.model.transaction(async (transaction) => { - return this.saveAgentSnapshot(parsed, transaction); - }); -} -``` - -- [ ] **Step 4: 增加控制器与路由,补配置项** - -```js -app.get('/api/agents/list', app.controller.agents.getAgentList); -app.get('/api/agents/detail', app.controller.agents.getAgentDetail); -app.get('/api/agents/related', app.controller.agents.getRelatedAgents); -app.get('/api/agents/asset', app.controller.agents.getAgentAsset); -app.post('/api/agents/import-file', app.controller.agents.importAgentFile); -app.post('/api/agents/delete', app.controller.agents.deleteAgent); -``` - -- [ ] **Step 5: 重跑服务测试确认通过** - -Run: `node --test test/agent-market-service.test.js` -Expected: PASS,覆盖导入成功、低版本拒绝、完整快照删除旧文件、资源路径校验、删除不影响 Skills - -### Task 3: 落地 Agent 市场列表页、详情页和前端交互 - -**Files:** -- Create: `app/web/pages/agents/index.tsx` -- Create: `app/web/pages/agents/types.ts` -- Create: `app/web/pages/agents/style.scss` -- Create: `app/web/pages/agents/detail/index.tsx` -- Create: `app/web/pages/agents/detail/AgentDetailContent.tsx` -- Create: `app/web/pages/agents/detail/style.scss` -- Modify: `app/web/router/index.ts` -- Modify: `app/web/layouts/header/header.tsx` -- Modify: `app/web/layouts/basicLayout/index.tsx` -- Modify: `app/web/api/url.ts` - -**Interfaces:** -- Consumes: `/api/agents/list`、`/api/agents/detail`、`/api/agents/related`、`/api/agents/import-file`、`/api/agents/delete` -- Produces: - - `/page/agents` - - `/page/agents/:name` - - `API.getAgentList / getAgentDetail / getRelatedAgents / importAgentFile / deleteAgent` - -- [ ] **Step 1: 先写前端契约测试或最小类型约束** - -```ts -export interface AgentItem { - name: string; - displayName: string; - logoUrl: string; -} -``` - -- [ ] **Step 2: 运行类型或构建校验,确认新页面尚未接入** - -Run: `npm run check-types` -Expected: FAIL,提示 `Cannot find module '@/pages/agents'` 或 API 类型缺失 - -- [ ] **Step 3: 最小实现列表页和详情页** - -```tsx - - - - - -``` - -- [ ] **Step 4: 接入导入弹窗、删除、相关 Agent 和按钮提示** - -```tsx - - -``` - -- [ ] **Step 5: 运行前端校验确认通过** - -Run: `npm run check-types` -Expected: PASS - -### Task 4: 补控制器集成测试与最终验证 - -**Files:** -- Create: `test/agent-market-controller.test.js` -- Modify: `test/agent-market-service.test.js` -- Modify: `docs/superpowers/specs/2026-08-11-agent-market-design.md` - -**Interfaces:** -- Consumes: `app/controller/agents.js`、`app/service/agents.js` -- Produces: 可回归的 Agent 市场后端测试集合 - -- [ ] **Step 1: 给控制器路由契约写失败测试** - -```js -test('Agent detail controller 返回统一 response 包装', async () => { - const controller = buildAgentsController({ getAgentDetail: async () => ({ name: 'bugfix-agent' }) }); - await controller.getAgentDetail(); - assert.equal(controller.ctx.body.success, true); -}); -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `node --test test/agent-market-controller.test.js test/agent-market-service.test.js` -Expected: FAIL,提示缺少 `app/controller/agents` - -- [ ] **Step 3: 完成控制器测试支撑并收口文档** - -```js -ctx.body = app.utils.response(true, data); -``` - -- [ ] **Step 4: 运行完整验证** - -Run: `node --test test/agent-market-controller.test.js test/agent-market-service.test.js` -Expected: PASS - -Run: `npm run check-types` -Expected: PASS - -- [ ] **Step 5: 提交最终实现** - -```bash -git add app/controller/agents.js app/service/agents.js app/web/pages/agents app/web/router/index.ts app/web/layouts/header/header.tsx app/web/layouts/basicLayout/index.tsx app/web/api/url.ts test/agent-market-controller.test.js test/agent-market-service.test.js docs/superpowers/plans/2026-08-11-agent-market.md -git commit -m "feat: implement agent market" -``` diff --git a/docs/superpowers/specs/2026-08-11-agent-market-design.md b/docs/superpowers/specs/2026-08-11-agent-market-design.md deleted file mode 100644 index a83ee2f6..00000000 --- a/docs/superpowers/specs/2026-08-11-agent-market-design.md +++ /dev/null @@ -1,407 +0,0 @@ -# Agent 市场设计 - -## 1. 背景与目标 - -Doraemon 新增 Agent 市场,用于展示、搜索、导入和更新 Agent。Doraemon 负责 Agent 的市场展示和安装包存储,不负责在线运行;Agent 最终由 Codex 等宿主执行。 - -第一版目标: - -- 提供 Agent 列表页和详情页 -- 通过 ZIP 导入单个 Agent -- 从 `agent.yaml` 提取市场展示字段 -- 保存 Agent 完整文件快照,为后续安装能力保留基础 -- 展示核心工作流和依赖 Skills -- 根据公共依赖 Skills 推荐相关 Agent - -第一版不包含: - -- Git 仓库自动同步 -- Agent 在线运行 -- Agent 安装和使用的真实功能 -- Stars、下载量、收藏和版本历史 -- 多 Agent ZIP 导入 - -## 2. 数据来源 - -正式运行时,Agent 列表和详情从数据库读取。Logo、Demo 等资源的元数据和相对路径从数据库读取,图片二进制从服务器持久化目录读取,不存入数据库。`agent-market` 本地目录不作为生产数据源,也不依赖相邻目录挂载。 - -开发环境如需直接预览本地资源,只能在 `config.local.js` 配置静态目录,不能将本机路径写入 `config.default.js`。 - -第一版按单机部署设计,生产资源根目录默认为 `/data/doraemon/agent-market`,并允许通过配置项覆盖。该目录必须位于持久化磁盘,不随应用发布、重启或临时文件清理而删除。 - -## 3. Agent ZIP 契约 - -### 3.1 目录结构 - -一个 ZIP 只能包含一个 Agent,压缩包顶层为一个 Agent 目录: - -```text -bugfix-agent/ -├── assets/ -│ ├── demo1.png -│ ├── demo2.png -│ └── logo.png -├── skills/ -│ └── bugfix-workflow/ -│ ├── agents/ -│ ├── references/ -│ ├── scripts/ -│ ├── tests/ -│ └── SKILL.md -├── subagents/ -│ ├── bugfix-reviewer.toml -│ └── bugfix-worker.toml -├── agent.yaml -├── MIGRATION.md -├── README.md -└── setup.sh -``` - -导入器忽略 `.DS_Store` 和 `__MACOSX`。解压后必须且只能发现一个 `agent.yaml`,且该文件必须位于唯一顶层 Agent 目录的根部。 - -### 3.2 文件安全 - -导入时拒绝: - -- 绝对路径、`../` 路径和路径穿越 -- 软链接和其他特殊文件 -- 重复路径和大小写冲突路径 -- 超出限制的 ZIP、文件数量、解压体积或单文件 - -默认限制:ZIP 最大 50MB、解压后最大 200MB、最多 500 个文件、单文件最大 20MB、单张展示图片最大 5MB。 - -保存非 `assets/` 文件时记录相对路径、MIME、大小、编码、内容和 Unix mode。`setup.sh` 等可执行文件在重新构建安装包时必须恢复执行权限。 - -`assets/` 仅允许普通文件,Logo 和 Demo 第一版支持 PNG、JPEG 和 WebP。导入时根据文件签名校验实际类型,不能只信任扩展名或上传的 `Content-Type`。 - -## 4. Manifest 契约 - -### 4.1 必填字段 - -```yaml -apiVersion: doraemon.dtstack.com/v1 -kind: Agent - -metadata: - name: bugfix-agent - displayName: Bugfix Agent - version: 1.0.0 - logo: ./assets/logo.png - description: Agent 简短描述 - author: - name: DTStack - category: 工程效率 - tags: - - Bugfix - -spec: - profile: Agent 详细简介 - entrypoint: - host: codex - type: skill - name: bugfix-workflow - ref: ./skills/bugfix-workflow -``` - -校验规则: - -- `apiVersion` 第一版只接受 `doraemon.dtstack.com/v1` -- `kind` 必须为 `Agent` -- `metadata.name` 是不可变唯一标识,只允许小写字母、数字和连字符,最大 100 字符 -- `metadata.version` 必须是 SemVer -- `metadata.category` 复用 Skills 分类:`通用`、`前端`、`后端`、`数据与AI`、`运维与系统`、`工程效率`、`安全`、`其他` -- 已声明的 Logo、Demo、入口 Skill 和 SubAgent 相对路径必须存在于 ZIP 中 -- 未识别的扩展字段不写入结构化列,但会随完整 `agent.yaml` 保存在 `agent_files` 中,不影响当前版本解析 - -### 4.2 页面字段映射 - -| Manifest 字段 | 用途 | -| --- | --- | -| `metadata.name` | 唯一标识、详情路由、更新匹配 | -| `metadata.displayName` | Agent 展示名称 | -| `metadata.version` | 当前版本 | -| `metadata.logo` | 列表和详情 Logo | -| `metadata.description` | 列表摘要和详情头部摘要 | -| `metadata.author.name` | 作者 | -| `metadata.category` | 单一分类 | -| `metadata.tags` | 多个检索和展示标签 | -| `spec.profile` | Agent 简介 | -| `spec.prompts` | 概览示例问题 | -| `spec.capabilities` | 概览中的“可以做什么” | -| `spec.entrypoint` | Agent 能力中的核心工作流 | -| `spec.dependencies.skills` | Agent 能力中的依赖 Skills、相关推荐依据 | -| `spec.demo.images` | 概览 Demo 图片和替代文本 | - -`spec.agents` 属于运行内部结构,第一版不在详情页单独展示。 - -### 4.3 Bugfix Agent 示例问题 - -```yaml -prompts: - - title: 修复 Bug 并部署 OMP online 环境 - prompt: "$bugfix-workflow 156343 dataApi 6.0.x,使用来源分支 dataApi/release_6.0.x,并部署到匹配的 OMP online 环境" - - title: 仅分析 Bug - prompt: "分析 Bug 156372,应用 batch,版本 6.2.x,只做根因分析,先不要修改代码" - - title: 指定 hotfix 与负责人 - prompt: "$bugfix-workflow 156460 stream 6.2.x hotfix zhaoge" -``` - -## 5. 数据模型 - -采用独立的 Agent 数据模型,不重构现有 Skills。 - -### 5.1 `agents` - -保存可查询的结构化字段: - -- `id` -- `name`,唯一索引 -- `display_name` -- `version` -- `description` -- `profile`,LONGTEXT -- `author_name` -- `category` -- `tags`,JSON 字符串 -- `prompts`,JSON 字符串 -- `capabilities`,JSON 字符串 -- `demo_images`,JSON 字符串,保存资源相对路径、MIME、大小、hash、alt 和顺序 -- `entrypoint_host` -- `entrypoint_type` -- `entrypoint_name` -- `entrypoint_ref` -- `logo_path`,保存资源相对路径 -- `logo_mime_type` -- `logo_size` -- `logo_hash` -- `content_hash` -- `source_file_name` -- `file_count` -- `is_delete` -- `created_at`、`updated_at` - -### 5.2 `agent_files` - -保存 Agent 除 `assets/` 外的完整文件快照: - -- `id` -- `agent_id` -- `file_path` -- `mime_type` -- `size` -- `is_binary` -- `encoding`,文本使用 `utf8`,二进制使用 `base64` -- `mode`,保存 Unix 文件权限 -- `content`,LONGTEXT -- `is_delete` -- `created_at`、`updated_at` - -`agent_id + file_path` 建立唯一索引。`assets/` 下的图片二进制不写入 `agent_files`,对应索引由 `agents.logo_*` 和 `agents.demo_images` 保存。 - -### 5.3 `agent_skills` - -保存核心工作流和公共 Skill 关系: - -- `id` -- `agent_id` -- `skill_slug` -- `skill_id`,允许为空 -- `relation_type`,`entrypoint` 或 `dependency` -- `sort_order` -- `created_at`、`updated_at` - -公共 Skill 尚未收录时仍保存 `skill_slug`。详情查询时按 slug 动态解析,后续 Skill 导入后无需重新导入 Agent。 - -### 5.4 Agent 资源目录 - -资源根目录由 `config.agentMarket.storageDir` 控制,生产环境配置为: - -```text -/data/doraemon/agent-market/ -└── / - └── / - └── assets/ - ├── logo.png - ├── demo1.png - └── demo2.png -``` - -数据库只保存相对于资源根目录的路径,例如 `bugfix-agent//assets/logo.png`,不保存绝对路径。`content-hash` 进入路径,用于避免同版本覆盖后的浏览器缓存污染,并支持新旧资源目录原子切换。 - -资源不能写入 `cache/uploads`,该目录只用于上传和解压过程中的临时文件。资源也不能直接读取相邻的 `../agent-market` 源目录。 - -## 6. 导入、更新与删除 - -### 6.1 导入流程 - -1. 接收一个 ZIP,并写入上传临时目录 -2. 安全解压到临时目录 -3. 校验单 Agent 目录结构和 `agent.yaml` -4. 校验所有 Manifest 文件引用 -5. 读取全部文件和权限,计算 `content_hash` -6. 按 `metadata.name` 查询现有 Agent -7. 新 Agent 直接创建;已有 Agent 进入更新规则 -8. 将 `assets/` 写入资源根目录下的临时目录,校验完成后原子重命名为 `//assets` -9. 在一个数据库事务中写入 Agent、非资源文件和 Skill 关系,并将资源相对路径指向新目录 -10. 数据库事务失败时删除本次新增资源目录;事务成功后删除该 Agent 的旧 hash 资源目录 -11. 成功或失败后删除上传 ZIP 和解压目录 -12. 清理 Agent 列表缓存并返回导入结果 - -### 6.2 更新规则 - -- 相同版本允许覆盖 -- 高版本允许升级 -- 低版本禁止覆盖高版本 -- `content_hash` 相同则返回“内容未变化”,不重复写入 -- 新 ZIP 是完整快照,新包不存在的旧文件和旧关系必须删除 -- 更新失败时旧版本保持不变 -- 第一版只保存当前版本,不保留版本历史 - -更新前端采用重新上传 ZIP,不提供直接修改数据库字段的编辑表单。首次检测到同名 Agent 时返回更新摘要并请求用户确认,确认后再次提交覆盖请求。 - -### 6.3 删除规则 - -删除时将 `agents.is_delete` 设置为 `1`,并物理删除对应的 `agent_files`、`agent_skills` 和服务器资源目录。不得删除任何 Skill 市场记录。重新导入相同 `metadata.name` 时恢复 Agent,并使用新 ZIP 重建文件、资源和关系。 - -数据库删除成功但资源目录清理失败时记录错误并进入后续清理,不回滚数据库删除结果。资源目录只能根据数据库中已校验的 Agent 名称和 hash 计算,禁止接受客户端传入的任意物理路径。 - -## 7. API 设计 - -第一版提供: - -- `GET /api/agents/list`:关键词、分类、分页查询 -- `GET /api/agents/detail`:Agent 详情、核心工作流、依赖 Skills -- `GET /api/agents/related`:相关 Agent -- `GET /api/agents/asset`:返回 Logo 或 Demo 二进制资源 -- `POST /api/agents/import-file`:导入或确认覆盖 Agent ZIP -- `POST /api/agents/delete`:软删除 Agent - -资源接口根据数据库记录定位资源相对路径,校验解析后的绝对路径仍位于资源根目录内,再以文件流返回。接口根据已保存 MIME 返回正确 `Content-Type`,并对带 hash 的资源设置长期缓存头。列表和详情接口只返回资源 URL,不内嵌 Base64,也不返回服务器绝对路径。 - -## 8. 列表页 - -导航栏在 `Skills` 相邻位置增加 `Agents`,路由为 `/page/agents`。 - -列表页沿用 Skill 市场的视觉语言: - -- 标题“Agent 市场” -- 副标题“发现并导入适用于不同研发场景的 Agent” -- 搜索名称、描述、标签和作者 -- 分类筛选复用 Skills 分类 -- 三列响应式卡片,移动端单列 -- 卡片展示 Logo、名称、描述、分类、最多三个标签、版本、作者、更新时间和依赖 Skill 数量 -- 默认按更新时间倒序 -- API 保留分页,当前总数不超过一页时不展示分页器 -- 不展示 Stars、下载量、收藏、多选、复制命令和排序器 -- 提供“导入 Agent”按钮 -- 更新入口只允许重新上传 ZIP,删除行为与 Skill 市场一致 - -## 9. 详情页 - -详情页路由为 `/page/agents/:name`,沿用 LobeHub 的信息层级,但保持 Doraemon 的现有视觉语言。 - -### 9.1 顶部区域 - -展示 Logo、名称、作者、版本、分类和标签。右侧提供“安装”和“使用”按钮;第一版点击后分别执行 `message.info('安装')` 和 `message.info('使用')`。 - -### 9.2 页签 - -详情页包含三个页签: - -- 概览 -- Agent 简介 -- Agent 能力 - -默认打开概览,页签状态不写入 URL。 - -### 9.3 概览 - -概览展示: - -- `metadata.description` -- `spec.capabilities` 能力卡片 -- `spec.prompts` 三个示例问题 -- `spec.demo.images` Demo 图片 - -Demo 图片按内容区宽度 `width: 100%` 展示,高度自适应,图片间距 16px,不使用轮播、缩略图或灯箱。 - -### 9.4 Agent 简介 - -渲染数据库中的 `profile` 字段,不直接读取或渲染 README。第一版按纯文本段落和换行展示,不启用任意 HTML。 - -### 9.5 Agent 能力 - -分为: - -- 核心工作流:展示 `spec.entrypoint` 对应的一个入口 Skill -- 依赖 Skills:按 `spec.dependencies.skills` 顺序展示公共 Skill - -已收录 Skill 可跳转 Skills 详情页;未收录 Skill 显示“暂未收录”,不提供跳转。 - -### 9.6 相关 Agent - -右侧最多展示三个相关 Agent。仅使用公共依赖 Skills 计算,不使用入口 Skill: - -1. 排除当前 Agent -2. 计算公共依赖 Skill 交集数量 -3. 过滤交集为 0 的 Agent -4. 按交集数量倒序 -5. 同分按更新时间倒序 - -没有结果时隐藏整个模块。移动端将操作按钮和相关 Agent 移到正文下方。 - -## 10. 异常与空状态 - -- Agent 不存在或已删除:展示空状态并返回 Agent 列表 -- Logo 缺失或加载失败:使用统一默认 Agent 图标 -- 单张 Demo 加载失败:保留位置并显示“图片加载失败”,其他图片继续展示 -- 依赖 Skill 未收录:展示不可跳转的缺失状态,不阻止 Agent 导入 -- ZIP 或 Manifest 校验失败:一次返回明确错误,不写入任何 Agent 数据 -- 低版本覆盖:返回当前版本和导入版本 -- 相同内容:返回“内容未变化” - -## 11. 测试与验收 - -后端测试至少覆盖: - -- 正常单 Agent ZIP 导入 -- 多 Agent、无 Agent、路径穿越、软链接和超限 ZIP 拒绝 -- Manifest 必填字段、分类、SemVer 和文件引用校验 -- 新增、同版本覆盖、高版本升级、低版本拒绝 -- 完整快照删除旧文件 -- 导入失败事务回滚 -- 非资源文件内容和 mode 保存 -- Logo、Demo 写入持久化目录,数据库不保存图片二进制 -- 资源路径穿越、伪造图片类型和超限图片拒绝 -- 覆盖成功切换新 hash 目录并清理旧目录 -- 数据库事务失败时清理本次新增资源目录 -- 缺失公共 Skill 仍可导入 -- 相关 Agent 交集排序和无结果隐藏 -- 删除 Agent 不影响 Skills - -前端验收至少覆盖: - -- 列表搜索、分类、空状态和响应式布局 -- 导入新增、覆盖确认、错误提示和内容未变化 -- 三个详情页签内容映射正确 -- Demo 图片宽度、高度和 16px 间距正确 -- 已收录与未收录 Skill 状态正确 -- 相关 Agent 排序、跳转和移动端布局正确 -- “安装”“使用”按钮弹出对应名称 - -## 12. 已确认决策 - -- 使用独立 `agents + agent_files + agent_skills`,不重构 Skills -- 一个 ZIP 只允许一个 Agent -- ZIP 顶层为单一 Agent 目录,`agent.yaml` 位于该目录根部 -- 仅手动 ZIP 导入,不做目录导入和自动同步 -- 相同版本覆盖,低版本禁止覆盖 -- 更新按完整快照处理 -- 不保留版本历史 -- 删除 Agent 不影响 Skills -- 分类复用 Skills,Tags 独立保存 -- 数据库是列表、详情和资源索引的正式数据源 -- Logo、Demo 图片保存在单机持久化目录 `/data/doraemon/agent-market`,数据库不保存图片二进制 -- Demo 原图顺序纵向展示,宽度撑满,高度自适应,间距 16px -- 详情页只渲染数据库字段,不直接渲染源文件 -- 第一版按钮只弹出按钮名称 diff --git a/env.json b/env.json index 328489a3..c6b71151 100644 --- a/env.json +++ b/env.json @@ -7,6 +7,7 @@ "proxyHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/%E4%BB%A3%E7%90%86%E6%9C%8D%E5%8A%A1", "skillsHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/dt-skill", "agentHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/agent-market", + "gitlabToken": "glpat-xxxxxxxxx", "mysql": { "prod": {} }, diff --git a/sql/doraemon.sql b/sql/doraemon.sql index 044ef152..2b406a2d 100644 --- a/sql/doraemon.sql +++ b/sql/doraemon.sql @@ -395,11 +395,13 @@ CREATE TABLE `agents` ( `capabilities` longtext COMMENT 'JSON 字符串数组', `logo_path` varchar(1000) DEFAULT NULL COMMENT 'Logo 相对路径', `logo_mime_type` varchar(100) DEFAULT NULL COMMENT 'Logo MIME', - `logo_size` int NOT NULL DEFAULT '0' COMMENT 'Logo 大小', + `logo_size` int DEFAULT NULL COMMENT 'Logo 大小', `logo_hash` varchar(128) DEFAULT NULL COMMENT 'Logo 哈希', `content_hash` varchar(128) NOT NULL COMMENT '内容哈希', - `source_file_name` varchar(255) DEFAULT NULL COMMENT '上传文件名', - `file_count` int NOT NULL DEFAULT '0' COMMENT '文件数量', + `git_url` varchar(1000) DEFAULT NULL COMMENT 'GitLab 仓库地址', + `git_branch` varchar(100) NOT NULL DEFAULT 'master' COMMENT 'GitLab 仓库分支', + `last_git_refresh_at` datetime DEFAULT NULL COMMENT '最近一次刷新/检查 Git 时间', + `last_git_sync_at` datetime DEFAULT NULL COMMENT '最近一次代码变动同步时间', `is_delete` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -409,28 +411,6 @@ CREATE TABLE `agents` ( KEY `idx_agents_updated_at` (`updated_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Agent 条目表'; --- ---------------------------- --- Table structure for agent_files --- ---------------------------- -DROP TABLE IF EXISTS `agent_files`; -CREATE TABLE `agent_files` ( - `id` int NOT NULL AUTO_INCREMENT, - `agent_id` int NOT NULL COMMENT 'agents.id', - `file_path` varchar(512) NOT NULL COMMENT 'Agent 内相对路径', - `mime_type` varchar(100) DEFAULT NULL COMMENT '文件 MIME', - `size` int NOT NULL DEFAULT '0' COMMENT '文件大小', - `is_binary` tinyint NOT NULL DEFAULT '0' COMMENT '是否二进制', - `encoding` varchar(20) NOT NULL DEFAULT 'utf8' COMMENT '内容编码', - `mode` int NOT NULL DEFAULT '0' COMMENT 'Unix 权限', - `content` longtext COMMENT '文件内容', - `is_delete` tinyint NOT NULL DEFAULT '0', - `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_agent_files_agent_path` (`agent_id`,`file_path`), - KEY `idx_agent_files_agent_id` (`agent_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Agent 文件快照表'; - -- ---------------------------- -- Table structure for agent_skills -- ---------------------------- diff --git a/test/agent-market-service.test.js b/test/agent-market-service.test.js index b31a58ac..259a2ca5 100644 --- a/test/agent-market-service.test.js +++ b/test/agent-market-service.test.js @@ -3,7 +3,6 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const AdmZip = require('adm-zip'); const AgentsService = require('../app/service/agents'); @@ -25,186 +24,12 @@ function createService() { config: { agentMarket: { storageDir: '/data/doraemon/agent-market', - maxZipSize: 50 * 1024 * 1024, - maxExtractedSize: 200 * 1024 * 1024, - maxFileCount: 500, - maxSingleFileSize: 20 * 1024 * 1024, - maxImageSize: 5 * 1024 * 1024, }, }, }; return service; } -function createPluginZip({ - codexManifest: codexOverrides = {}, - claudeManifest: claudeOverrides = {}, - includeClaudeManifest = true, - logoPath = 'assets/logo.png', - extraEntries = [], -} = {}) { - const zip = new AdmZip(); - const root = 'bugfix-agent'; - const codexManifest = { - name: root, - version: '1.0.0', - description: 'Agent 简短描述', - author: { name: 'DTStack' }, - keywords: ['Bugfix', 'Review'], - skills: './skills/', - interface: { - displayName: 'Bugfix Agent', - longDescription: '负责 Bug 分析、修复和回归验证', - developerName: 'DTStack', - category: 'Coding', - capabilities: ['分析 Bug', '修复代码'], - defaultPrompt: ['$bugfix-workflow 156343 dataApi/release_6.0.x'], - logo: `./${logoPath}`, - }, - ...codexOverrides, - }; - const claudeManifest = { - name: root, - version: '1.0.0', - description: 'Agent 简短描述', - author: { name: 'DTStack' }, - agents: ['./agents/claude/bugfix-worker.md'], - ...claudeOverrides, - }; - - zip.addFile( - `${root}/.codex-plugin/plugin.json`, - Buffer.from(JSON.stringify(codexManifest), 'utf8') - ); - if (includeClaudeManifest) { - zip.addFile( - `${root}/.claude-plugin/plugin.json`, - Buffer.from(JSON.stringify(claudeManifest), 'utf8') - ); - } - zip.addFile( - `${root}/skills/bugfix-workflow/SKILL.md`, - Buffer.from('# Bugfix Workflow\n', 'utf8') - ); - zip.addFile( - `${root}/agents/claude/bugfix-worker.md`, - Buffer.from('---\nname: bugfix-worker\ndescription: worker\n---\n', 'utf8') - ); - zip.addFile(`${root}/${logoPath}`, Buffer.from('logo', 'utf8')); - zip.addFile(`${root}/README.md`, Buffer.from('# Bugfix Agent\n', 'utf8')); - extraEntries.forEach((entry) => { - zip.addFile(entry.name, Buffer.from(entry.content || '', entry.encoding || 'utf8')); - }); - - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-market-test-')); - const zipPath = path.join(tempDir, 'bugfix-agent.zip'); - zip.writeZip(zipPath); - return { - zipPath, - cleanup() { - fs.rmSync(tempDir, { recursive: true, force: true }); - }, - }; -} - -test('parseAgentZip 解析双宿主 plugin 并返回规范展示字段', async () => { - const fixture = createPluginZip(); - - try { - const parsed = await createService().parseAgentZip(fixture.zipPath); - assert.equal(parsed.agent.name, 'bugfix-agent'); - assert.equal(parsed.agent.displayName, 'Bugfix Agent'); - assert.equal(parsed.agent.version, '1.0.0'); - assert.equal(parsed.agent.category, '工程效率'); - assert.equal(parsed.agent.authorName, 'DTStack'); - assert.equal(parsed.agent.longDescription, '负责 Bug 分析、修复和回归验证'); - assert.deepEqual(parsed.agent.defaultPrompt, [ - '$bugfix-workflow 156343 dataApi/release_6.0.x', - ]); - assert.deepEqual(parsed.agent.keywords, ['Bugfix', 'Review']); - assert.equal(parsed.agent.logo.path.startsWith('bugfix-agent/'), true); - assert.equal('profile' in parsed.agent, false); - assert.equal('prompts' in parsed.agent, false); - assert.equal('entrypointName' in parsed.agent, false); - assert.equal('skillRelations' in parsed, false); - assert.equal( - parsed.files.some((item) => item.filePath === 'assets/logo.png'), - false - ); - assert.equal( - parsed.files.some((item) => item.filePath === '.claude-plugin/plugin.json'), - true - ); - assert.equal( - parsed.files.some((item) => item.filePath === 'skills/bugfix-workflow/SKILL.md'), - true - ); - } finally { - fixture.cleanup(); - } -}); - -test('parseAgentZip 拒绝缺少 Claude Code manifest 的 plugin', async () => { - const fixture = createPluginZip({ includeClaudeManifest: false }); - - try { - await assert.rejects( - () => createService().parseAgentZip(fixture.zipPath), - /\.claude-plugin\/plugin\.json/ - ); - } finally { - fixture.cleanup(); - } -}); - -test('parseAgentZip 拒绝双 manifest 的版本不一致', async () => { - const fixture = createPluginZip({ claudeManifest: { version: '2.0.0' } }); - - try { - await assert.rejects( - () => createService().parseAgentZip(fixture.zipPath), - /version 必须一致/ - ); - } finally { - fixture.cleanup(); - } -}); - -test('parseAgentZip 拒绝超过 Codex 限制的默认 prompt', async () => { - const fixture = createPluginZip({ - codexManifest: { - interface: { - displayName: 'Bugfix Agent', - longDescription: '描述', - developerName: 'DTStack', - category: 'Coding', - defaultPrompt: ['1', '2', '3', '4'], - logo: './assets/logo.png', - }, - }, - }); - - try { - await assert.rejects( - () => createService().parseAgentZip(fixture.zipPath), - /defaultPrompt 最多支持 3 条/ - ); - } finally { - fixture.cleanup(); - } -}); - -test('parseAgentZip 支持 Codex 官方 .codex-plugin/assets Logo 路径', async () => { - const fixture = createPluginZip({ logoPath: '.codex-plugin/assets/logo.png' }); - - try { - const parsed = await createService().parseAgentZip(fixture.zipPath); - assert.match(parsed.agent.logo.path, /\.codex-plugin\/assets\/logo\.png$/); - } finally { - fixture.cleanup(); - } -}); - test('normalizeCapabilities 兼容字符串和对象数组', () => { const service = createService(); @@ -214,35 +39,6 @@ test('normalizeCapabilities 兼容字符串和对象数组', () => { ]); }); -test('compareAgentVersion 按 semver 比较版本号', () => { - const service = createService(); - - assert.equal(service.compareAgentVersion('1.0.0', '1.0.0'), 0); - assert.equal(service.compareAgentVersion('1.0.1', '1.0.0'), 1); - assert.equal(service.compareAgentVersion('1.2.0', '1.10.0'), -1); -}); - -test('writeAgentArchive 将原始 ZIP 保存到当前内容 hash 目录', async () => { - const service = createService(); - const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-archive-storage-')); - const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-archive-source-')); - const sourcePath = path.join(sourceDir, 'source.zip'); - fs.writeFileSync(sourcePath, Buffer.from('original-agent-zip')); - service.app.config.agentMarket.storageDir = storageDir; - - try { - const archiveDir = await service.writeAgentArchive( - { name: 'bugfix-agent', contentHash: 'hash-v2' }, - sourcePath - ); - const archivePath = path.join(archiveDir, 'bugfix-agent.zip'); - assert.equal(fs.readFileSync(archivePath, 'utf8'), 'original-agent-zip'); - } finally { - fs.rmSync(storageDir, { recursive: true, force: true }); - fs.rmSync(sourceDir, { recursive: true, force: true }); - } -}); - test('getAgentArchiveStream 返回当前 hash 对应的原始 ZIP', async () => { const service = createService(); const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-archive-download-')); @@ -397,14 +193,13 @@ test('getRelatedAgents 根据技能重叠数降序推荐相关 Agent 并排除 assert.equal(result[1].overlapCount, 1); }); -test('deleteAgent 软删除 Agent 并清理 AgentFile 与 AgentSkill 关联数据', async () => { +test('deleteAgent 软删除 Agent 并清理 AgentSkill 关联数据', async () => { const service = createService(); service.storageReady = true; service.getAgentMarketConfig = () => ({ storageDir: '/tmp/test-storage' }); service.removeDirectory = () => {}; let agentUpdated = false; - let filesDestroyed = false; let skillsDestroyed = false; service.app.model = { @@ -418,13 +213,6 @@ test('deleteAgent 软删除 Agent 并清理 AgentFile 与 AgentSkill 关联数 } }, }, - AgentFile: { - async destroy({ where }) { - if (where.agent_id === 10) { - filesDestroyed = true; - } - }, - }, AgentSkill: { async destroy({ where }) { if (where.agent_id === 10) { @@ -440,26 +228,9 @@ test('deleteAgent 软删除 Agent 并清理 AgentFile 与 AgentSkill 关联数 const res = await service.deleteAgent({ name: 'test-agent' }); assert.equal(res.deleted, true); assert.equal(agentUpdated, true); - assert.equal(filesDestroyed, true); assert.equal(skillsDestroyed, true); }); -test('parseAgentZip 过滤 .codex-plugin/assets 避免二进制图片存入快照文件列表', async () => { - const service = createService(); - const fixture = createPluginZip({ logoPath: '.codex-plugin/assets/logo.png' }); - - try { - const parsed = await service.parseAgentZip(fixture.zipPath); - assert.match(parsed.agent.logo.path, /\.codex-plugin\/assets\/logo\.png$/); - const hasAssetInFiles = parsed.files.some((f) => - f.filePath.startsWith('.codex-plugin/assets/') - ); - assert.equal(hasAssetInFiles, false); - } finally { - fixture.cleanup(); - } -}); - test('ensureAgentSkillsTableCompatible 兼容处理历史 relation_type 非空约束', async () => { const service = createService(); let changed = false; @@ -554,3 +325,151 @@ test('queryAgentList 返回列表中每个 Agent 的 skillCount 统计', async ( assert.equal(res.list[1].name, 'agent-102'); assert.equal(res.list[1].skillCount, 1); }); + +test('formatGitCloneError 格式化并简化 Git 报错信息', () => { + const service = createService(); + + // 远程分支不存在时返回简洁明确的提示 + const branchErrZh = { + stderr: Buffer.from( + "正克隆到 'operator-register-agent'...\n警告:重定向到 http://gitlab.prod.dtstack.cn/repo.git/\n致命错误:远程分支 master 在上游 origin 未发现\n" + ), + }; + assert.equal( + service.formatGitCloneError(branchErrZh, 'master'), + '未在远端仓库找到分支「master」,请在设置中检查分支名称(如 master 或 main)' + ); + + const branchErrEn = { + stderr: Buffer.from( + "Cloning into 'operator-register-agent'...\nfatal: Remote branch feat/x not found in upstream origin\n" + ), + }; + assert.equal( + service.formatGitCloneError(branchErrEn, 'feat/x'), + '未在远端仓库找到分支「feat/x」,请在设置中检查分支名称(如 master 或 main)' + ); + + // 认证失败提示 + const authErr = { + stderr: Buffer.from( + "fatal: Authentication failed for 'http://gitlab.prod.dtstack.cn/repo.git'\n" + ), + }; + assert.equal( + service.formatGitCloneError(authErr), + 'Git 认证失败,请检查 env.json 或环境变量中是否配置了有效的 gitlabToken' + ); + + // 仓库不存在提示 + const notFoundErr = { + stderr: Buffer.from( + "remote: The project you were looking for could not be found.\nfatal: repository 'xxx' not found\n" + ), + }; + assert.equal( + service.formatGitCloneError(notFoundErr), + '未找到远程仓库,请检查仓库地址是否正确或是否有权限访问' + ); + + // 网络连接失败提示 + const networkErr = { + stderr: Buffer.from( + "fatal: unable to access 'http://gitlab.prod.dtstack.cn/...': Could not resolve host\n" + ), + }; + assert.equal( + service.formatGitCloneError(networkErr), + '连接远程仓库失败,请检查网络连接或仓库地址' + ); + + // 未知错误过滤进度与警告日志,提取核心 fatal 信息 + const unknownErr = { + stderr: Buffer.from( + "正克隆到 'test'...\n警告:重定向到 http://gitlab.prod.dtstack.cn/test.git/\n致命错误:磁盘空间不足\n" + ), + }; + assert.equal(service.formatGitCloneError(unknownErr), '磁盘空间不足'); +}); + +test('normalizeGitSource 解析并规范化 Git 仓库地址及分支', () => { + const service = createService(); + + // 1. 标准 Git URL + const res1 = service.normalizeGitSource( + 'http://gitlab.prod.dtstack.cn/frontend/my-agent.git', + 'master' + ); + assert.equal(res1.cleanGitUrl, 'http://gitlab.prod.dtstack.cn/frontend/my-agent.git'); + assert.equal(res1.targetBranch, 'master'); + assert.equal(res1.repoName, 'my-agent'); + + // 2. 网页端 URL 带多级斜杠分支(自动补齐 .git 规范后缀) + const res2 = service.normalizeGitSource( + 'http://gitlab.prod.dtstack.cn/frontend/my-agent/-/tree/feat/feature-1' + ); + assert.equal(res2.cleanGitUrl, 'http://gitlab.prod.dtstack.cn/frontend/my-agent.git'); + assert.equal(res2.targetBranch, 'feat/feature-1'); + assert.equal(res2.repoName, 'my-agent'); + + // 3. 用户显式指定分支优先于 URL 中解析的分支 + const res3 = service.normalizeGitSource( + 'http://gitlab.prod.dtstack.cn/frontend/my-agent/-/tree/dev', + 'release/1.0.0' + ); + assert.equal(res3.targetBranch, 'release/1.0.0'); + + // 4. 拦截非法仓库名称(如包含路径遍历符号 ..) + assert.throws( + () => { + service.normalizeGitSource('http://gitlab.prod.dtstack.cn/frontend/..'); + }, + (err) => err.status === 400 && err.message.includes('非法的 Git 仓库名称') + ); + + // 5. 拦截以选项参数 - 或 . 开头的非法分支名 + assert.throws( + () => { + service.normalizeGitSource( + 'http://gitlab.prod.dtstack.cn/frontend/my-agent.git', + '-oProxyCommand=calc' + ); + }, + (err) => err.status === 400 && err.message.includes('非法的分支名称') + ); + + assert.throws( + () => { + service.normalizeGitSource( + 'http://gitlab.prod.dtstack.cn/frontend/my-agent.git', + '../release' + ); + }, + (err) => err.status === 400 && err.message.includes('非法的分支名称') + ); +}); + +test('getGitAuthArgs 域名白名单与空 host 防护', () => { + const service = createService(); + service.resolveGitlabToken = () => 'test-token-123'; + service.resolveGitlabHostWhitelist = () => ['gitlab.prod.dtstack.cn']; + + // 1. 合法白名单域名 + const auth1 = service.getGitAuthArgs('http://gitlab.prod.dtstack.cn/frontend/my-agent.git'); + assert.equal(auth1.length, 2); + assert.equal(auth1[0], '-c'); + assert.match(auth1[1], /^http\.extraHeader=Authorization: Basic [A-Za-z0-9+/=]+$/); + const expectedBasic = Buffer.from('oauth2:test-token-123').toString('base64'); + assert.equal(auth1[1], `http.extraHeader=Authorization: Basic ${expectedBasic}`); + + // 2. 非白名单域名,不透传 Token + const auth2 = service.getGitAuthArgs('https://github.com/external/repo.git'); + assert.deepEqual(auth2, []); + + // 3. 非法 URL 或空 host,不透传 Token + const auth3 = service.getGitAuthArgs('not-a-valid-url'); + assert.deepEqual(auth3, []); + + const auth4 = service.getGitAuthArgs(''); + assert.deepEqual(auth4, []); +}); diff --git a/test/agent-plugin-contract.test.js b/test/agent-plugin-contract.test.js index 7c884170..9f1cd31d 100644 --- a/test/agent-plugin-contract.test.js +++ b/test/agent-plugin-contract.test.js @@ -1,9 +1,5 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const AdmZip = require('adm-zip'); const AgentsService = require('../app/service/agents'); @@ -20,21 +16,16 @@ function createService() { config: { agentMarket: { storageDir: '/data/doraemon/agent-market', - maxExtractedSize: 200 * 1024 * 1024, - maxFileCount: 500, - maxSingleFileSize: 20 * 1024 * 1024, }, }, }; return service; } -function createPluginZip({ includeClaudeManifest = true, version = '1.0.0' } = {}) { - const zip = new AdmZip(); - const root = 'bugfix-agent'; +test('validateCodexManifest 返回规范化的展示与配置字段', () => { const codexManifest = { - name: root, - version, + name: 'bugfix-agent', + version: '1.0.0', description: 'Bugfix plugin', author: { name: 'DTStack' }, keywords: ['bugfix'], @@ -49,69 +40,19 @@ function createPluginZip({ includeClaudeManifest = true, version = '1.0.0' } = { logo: './assets/logo.png', }, }; - const claudeManifest = { - name: root, - version, - description: 'Bugfix plugin', - author: { name: 'DTStack' }, - agents: ['./agents/claude/bugfix-worker.md'], - }; - - zip.addFile( - `${root}/.codex-plugin/plugin.json`, - Buffer.from(JSON.stringify(codexManifest), 'utf8') - ); - if (includeClaudeManifest) { - zip.addFile( - `${root}/.claude-plugin/plugin.json`, - Buffer.from(JSON.stringify(claudeManifest), 'utf8') - ); - } - zip.addFile(`${root}/skills/bugfix-workflow/SKILL.md`, Buffer.from('# Bugfix Workflow\n')); - zip.addFile( - `${root}/agents/claude/bugfix-worker.md`, - Buffer.from('---\nname: bugfix-worker\ndescription: worker\n---\n') - ); - zip.addFile(`${root}/assets/logo.png`, Buffer.from('logo')); - - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-plugin-contract-')); - const zipPath = path.join(tempDir, 'bugfix-agent.zip'); - zip.writeZip(zipPath); - return { - zipPath, - cleanup() { - fs.rmSync(tempDir, { recursive: true, force: true }); - }, - }; -} - -test('parseAgentZip 只返回双 manifest 的规范展示字段', async () => { - const fixture = createPluginZip(); - - try { - const parsed = await createService().parseAgentZip(fixture.zipPath); - assert.equal(parsed.agent.longDescription, '负责 Bug 分析、修复和交付'); - assert.deepEqual(parsed.agent.defaultPrompt, ['$bugfix-workflow 156343']); - assert.equal('profile' in parsed.agent, false); - assert.equal('prompts' in parsed.agent, false); - assert.equal('entrypointName' in parsed.agent, false); - assert.equal('skillRelations' in parsed, false); - } finally { - fixture.cleanup(); - } + const validated = createService().validateCodexManifest(codexManifest); + assert.equal(validated.longDescription, '负责 Bug 分析、修复和交付'); + assert.deepEqual(validated.defaultPrompt, ['$bugfix-workflow 156343']); + assert.equal(validated.displayName, 'Bugfix Agent'); + assert.equal(validated.category, '工程效率'); }); -test('parseAgentZip 拒绝缺少 Claude Code manifest 的 plugin', async () => { - const fixture = createPluginZip({ includeClaudeManifest: false }); - - try { - await assert.rejects( - () => createService().parseAgentZip(fixture.zipPath), - /\.claude-plugin\/plugin\.json/ - ); - } finally { - fixture.cleanup(); - } +test('validateClaudeManifest 校验 agents 配置有效性', () => { + const service = createService(); + assert.throws( + () => service.validateClaudeManifest({ name: 'bugfix-agent', agents: [] }), + /\.claude-plugin\/plugin\.json 必须声明 agents/ + ); }); test('getAgentDetail 只返回 plugin 展示契约字段', async () => { diff --git a/test/agent-schedule-sync.test.js b/test/agent-schedule-sync.test.js new file mode 100644 index 00000000..e1db648d --- /dev/null +++ b/test/agent-schedule-sync.test.js @@ -0,0 +1,59 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const syncScheduleFactory = require('../app/schedule/syncGitAgents'); + +test('syncGitAgents 定时任务默认配置 5m 且工作在 worker 模式', () => { + const mockApp = { + config: { + agentMarket: { + autoSyncInterval: '5m', + }, + }, + }; + const scheduleDef = syncScheduleFactory(mockApp); + assert.equal(scheduleDef.schedule.interval, '5m'); + assert.equal(scheduleDef.schedule.type, 'worker'); + assert.equal(scheduleDef.schedule.disable, false); +}); + +test('syncGitAgents 支持禁用定时任务', () => { + const mockApp = { + config: { + agentMarket: { + autoSyncInterval: '', + }, + }, + }; + const scheduleDef = syncScheduleFactory(mockApp); + assert.equal(scheduleDef.schedule.disable, true); +}); + +test('syncGitAgents 定时任务调用 service.agents.syncAllGitAgents', async () => { + let called = false; + const mockApp = { + config: { + agentMarket: { + autoSyncInterval: '5m', + }, + }, + }; + const scheduleDef = syncScheduleFactory(mockApp); + const mockCtx = { + logger: { + info() {}, + error() {}, + }, + service: { + agents: { + async syncAllGitAgents() { + called = true; + return [{ name: 'demo-agent', success: true, isContentChanged: true }]; + }, + }, + }, + }; + + await scheduleDef.task(mockCtx); + assert.equal(called, true, '应当调用 syncAllGitAgents'); +}); diff --git a/test/skills-install-key.test.js b/test/skills-install-key.test.js index 83ab64aa..6f1dbc9a 100644 --- a/test/skills-install-key.test.js +++ b/test/skills-install-key.test.js @@ -299,7 +299,7 @@ test('persistSkillsForSource - TDD scenarios for web upload', async () => { const result2 = await service.persistSkillsForSource(10, sourceMeta, [record1]); assert.equal(result2.length, 1); assert.equal(dbSkills.length, 1); - assert.equal(dbSkills[0].description, 'new desc'); + assert.equal(dbSkills[0].description, 'desc'); // 3. 不同名同 Slug 导入冲突(应抛出 400 错误:slug 已存在) const recordConflict = {