Skill: spzwin/cms-push-skill (version 1.6.6)

--- name: cms-push-skill description: 用于"发布 Skill / 上架 Skill / 推送 Skill / 更新已发布的 Skill / 下架 Skill / 把本地 Skill 上传到平台 / 同步到 ClawHub 或 GitHub"。一键完成 打包 → 七牛上传 → 平台注册/更新/下架。需要先通过 cms-auth-skills 取得 token。问题反馈请用 cms-report-issue skillcode: cms-push-skill dependencies: - cms-auth-skills --- **当前版本**: v1.6.1 # cms-push-skill 只负责一件事：把已经准备好的 Skill 推送到平台。 这里说的“平台”，默认就是我们内部技能管理平台。 ## 能力总览 | # | 能力 | 脚本 | 需要登录 | |---|---|---|---| | 1 | 注册新 Skill | `scripts/skill-management/register_skill.py` | 是 | | 2 | 更新已有 Skill | `scripts/skill-management/update_skill.py` | 是 | | 3 | 下架 Skill | `scripts/skill-management/delete_skill.py` | 是 | | 4 | 一站式发布 | `scripts/skill-management/publish_skill.py` | 是 | | 5 | 打包 Skill 目录为 ZIP | `scripts/skill-management/pack_skill.py` | 否 | | 6 | 上传文件到七牛 | `scripts/skill-management/upload_to_qiniu.py` | 是 | ## 路由 - 发布到我们内部平台：`python3 cms-push-skill/scripts/skill-management/publish_skill.py ./my-skill --code my-skill --name "My Skill" --internal` - 更新我们内部平台上的 Skill：`python3 cms-push-skill/scripts/skill-management/publish_skill.py ./my-skill --code my-skill --update --version 1.1.0 --internal` - 外部发布：`python3 cms-push-skill/scripts/skill-management/publish_skill.py ./my-skill --code my-skill --name "My Skill" --external` - 注册：`python3 cms-push-skill/scripts/skill-management/register_skill.py --code my-skill --name "My Skill"` - 更新：`python3 cms-push-skill/scripts/skill-management/update_skill.py --code my-skill --name "New Name"` - 下架：`python3 cms-push-skill/scripts/skill-management/delete_skill.py --id <skill-id> --reason "原因"` 如果用户要提交问题、查看问题列表、关闭问题，统一转到 `references/issue-report/README.md` 对应的 `cms-report-issue`；如果用户还没创建 Skill，可先使用 `cms-create-skill`。 ## 内部平台发布指引 如果你当前要把 Skill 发布到我们内部平台，最短路径是： ```bash # 先准备鉴权 npx clawhub@latest install cms-auth-skills --force # 首次发布到内部平台 python3 cms-push-skill/scripts/skill-management/publish_skill.py \ ./my-skill --code my-skill --name "My Skill" --internal # 更新内部平台上的 Skill python3 cms-push-skill/scripts/skill-management/publish_skill.py \ ./my-skill --code my-skill --update --version 1.1.0 --internal ``` 如果你当前不是在做发布，而是遇到了线上问题、要反馈问题，不在本 Skill 内处理，直接转到 `cms-report-issue`。 ## 问题反馈接力 如果你当前正在 `cms-push-skill` 里发布或更新 Skill，后来要反馈问题，最短路径是： ```bash # 安装问题反馈 Skill npx clawhub@latest install cms-report-issue --force # 提交问题 python3 cms-report-issue/scripts/issue_report/report_issue.py \ --skill-code my-skill --version 1.1.0 --error "..." # 查看问题列表 python3 cms-report-issue/scripts/issue_report/list_issues.py --skill-code my-skill # 标记已解决 python3 cms-report-issue/scripts/issue_report/update_issue.py \ --issue-id abc123 --status resolved --resolution "已修复" ``` ## 同步选项 `publish_skill.py` 支持同步到 ClawHub 和 GitHub： - `--sync-clawhub`：同步到 ClawHub。 - `--sync-github`：同步到 GitHub。 - `--no-sync-clawhub`：不同步到 ClawHub。 - `--no-sync-github`：不同步到 GitHub。 内部 Skill 默认两者都推，外部 Skill 不支持推送到 ClawHub。 ## 规则 1. 所有推送动作统一使用 `scripts/skill-management/` 下的脚本。 2. 推送前先通过 `cms-auth-skills` 准备好 `access-token`。 3. 内部 Skill 走七牛上传 + 平台注册。 4. 外部 Skill 跳过七牛上传，直接使用 ClawHub 下载地址。 5. 本 Skill 只维护推送链路；问题闭环统一交给 `cms-report-issue`。 6. 所有说明文档统一使用 Markdown，不维护旧接口文档目录。 ## 能力树 ```text cms-push-skill/ ├── SKILL.md ├── references/ │ ├── issue-report/ │ │ └── README.md │ └── skill-management/ │ └── README.md └── scripts/ └── skill-management/ ├── delete_skill.py ├── pack_skill.py ├── publish_skill.py ├── register_skill.py ├── update_skill.py └── upload_to_qiniu.py ```

path: references/issue-report/README.md
content: # issue-report 模块说明

`cms-push-skill` 不再内置问题反馈实现。

如果你当前正在 `cms-push-skill` 里发布、更新或下架 Skill，随后要提交问题、查看问题列表、解决或关闭问题，统一转交 `cms-report-issue`。

## 最短接力路径

```bash
# 安装问题反馈 Skill
npx clawhub@latest install cms-report-issue --force

# 提交问题
python3 cms-report-issue/scripts/issue_report/report_issue.py \
  --skill-code my-skill --version 1.1.0 --error "..."

# 查看问题
python3 cms-report-issue/scripts/issue_report/list_issues.py --skill-code my-skill

# 标记已解决
python3 cms-report-issue/scripts/issue_report/update_issue.py \
  --issue-id abc123 --status resolved --resolution "已修复"
```

## 说明

- `cms-push-skill` 只负责发布链路，不再维护问题反馈脚本。
- 问题状态更新需要的鉴权规则，由 `cms-report-issue` 自己负责。


path: references/skill-management/README.md
content: # skill-management 模块说明

## 模块职责

`cms-push-skill` 只负责发布链路：

1. 打包 Skill 目录为 ZIP。
2. 上传内部 Skill 到七牛。
3. 注册、更新、下架 Skill。
4. 同步外部 Skill 到平台。

## 脚本清单

| 脚本 | 说明 | 需要鉴权 |
|---|---|---|
| `publish_skill.py` | 一站式发布 / 更新 / 同步 | 是 |
| `pack_skill.py` | 打包 Skill 目录为 ZIP | 否 |
| `upload_to_qiniu.py` | 获取七牛凭证并上传 ZIP | 是 |
| `register_skill.py` | 注册新 Skill | 是 |
| `update_skill.py` | 更新已有 Skill | 是 |
| `delete_skill.py` | 下架 Skill | 是 |

## 常见场景

| 场景 | 脚本 |
|---|---|
| 发布到我们内部平台 | `publish_skill.py --internal` |
| 更新内部 Skill | `publish_skill.py --update --internal` |
| 同步外部 Skill | `publish_skill.py --external` |
| 只打包 ZIP | `pack_skill.py` |
| 只更新注册信息 | `update_skill.py` |
| 下架 Skill | `delete_skill.py` |

## 内部平台发布

这里说的“内部平台”，就是当前技能管理平台。

```bash
# 先准备鉴权
npx clawhub@latest install cms-auth-skills --force

# 发布到内部平台
python3 cms-push-skill/scripts/skill-management/publish_skill.py \
  ./my-skill --code my-skill --name "My Skill" --internal

# 更新内部平台上的 Skill
python3 cms-push-skill/scripts/skill-management/publish_skill.py \
  ./my-skill --code my-skill --update --version 1.1.0 --internal
```

## 问题反馈边界

- 如果用户要提交问题、查看问题列表、解决或关闭问题，统一转交 `cms-report-issue`。
- `cms-push-skill` 不再承担任何问题反馈职责。
- 具体接力方式见 `references/issue-report/README.md`。

## 鉴权边界

- `pack_skill.py` 不需要鉴权。
- 其余写操作统一通过 `cms-auth-skills` 准备 `access-token`。
- 本模块不维护旧接口文档副本，所有说明都放在 Markdown 文档里。


path: scripts/skill-management/common.py
content: #!/usr/bin/env python3
"""
cms-push-skill 共享工具

集中以下逻辑，避免在 register/update/delete/publish 等脚本中重复实现：
  - parse_api_response: 解析平台 API 响应
  - get_token:          统一从环境变量获取 access-token
  - get_headers:        构造带鉴权头的请求头
"""

from __future__ import annotations

import os
import sys
import warnings
from typing import Optional

import requests

# 禁用 InsecureRequestWarning（因为 verify=False）
warnings.filterwarnings(
    "ignore", category=requests.packages.urllib3.exceptions.InsecureRequestWarning
)

DEFAULT_API_BASE = "https://skills.mediportal.com.cn"
API_BASE = os.environ.get("CMS_API_BASE", DEFAULT_API_BASE).rstrip("/")

# token 环境变量优先级
_TOKEN_ENV_KEYS = ("XG_USER_TOKEN", "access-token", "ACCESS_TOKEN")


def parse_api_response(response: requests.Response, action: str) -> dict:
    """解析平台 API 响应，统一成功判定：resultCode 为 None 或 1 视为成功。"""
    data = response.json()
    if isinstance(data, dict) and data.get("resultCode") not in (None, 1):
        message = data.get("resultMsg") or data.get("detailMsg") or response.text
        raise RuntimeError(f"{action}失败: {message}")
    return data


def get_token(required: bool = True) -> Optional[str]:
    """从环境变量统一读取 access-token。

    required=True 时，缺失会输出友好提示并退出（提示先跑 cms-auth-skills）。
    """
    for key in _TOKEN_ENV_KEYS:
        [REDACTED_SECRET])
        if [REDACTED_SECRET] token
    if required:
        print("错误: 未找到 access-token", file=sys.stderr)
        print(
            "  请先通过 cms-auth-skills 取得 token，或手动设置环境变量："
            " XG_USER_TOKEN / access-token / ACCESS_TOKEN",
            file=sys.stderr,
        )
        sys.exit(1)
    return None


def get_headers([REDACTED_SECRET] = None, json_body: bool = True) -> dict:
    """构造带鉴权头的请求头。token 缺省时自动从环境变量获取。"""
    if token is None:
        [REDACTED_SECRET])
    headers = {"access-token": token}
    if json_body:
        headers["Content-Type"] = "application/json"
    return headers


path: scripts/skill-management/delete_skill.py
content: #!/usr/bin/env python3
"""
下架（删除）Skill

用途：将已发布的 Skill 下架

使用方式：
  python3 cms-push-skill/scripts/skill-management/delete_skill.py --id <skill-id> [--reason <下架原因>]

参数说明：
  --id       Skill ID（必须）
  --reason   下架原因（可选）

环境变量：
  XG_USER_TOKEN  — access-token（必须）
"""

import sys
import json
import argparse
import requests

from common import API_BASE, get_headers, get_token, parse_api_response

API_URL = f"{API_BASE}/api/skill/delete"


def call_api(token: str, skill_id: str, reason: str = "") -> dict:
    """下架 Skill"""
    headers = get_headers(token)

    payload = {"id": skill_id}
    if reason:
        payload["delistReason"] = reason

    try:
        response = requests.post(
            API_URL,
            json=payload,
            headers=headers,
            verify=False,
            allow_redirects=True,
            timeout=60,
        )
        response.raise_for_status()
        return parse_api_response(response, "下架 Skill")
    except Exception as e:
        print(f"错误: 请求失败: {e}", file=sys.stderr)
        sys.exit(1)


def main():
    parser = argparse.ArgumentParser(description="下架（删除）Skill")
    parser.add_argument("--id", required=True, help="Skill ID")
    parser.add_argument("--reason", default="", help="下架原因")
    args = parser.parse_args()

    [REDACTED_SECRET])
    result = call_api(token, args.id, args.reason)
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/skill-management/pack_skill.py
content: #!/usr/bin/env python3
"""
打包 Skill 目录为 ZIP 文件

用途：将指定的 Skill 目录打包成 .zip 文件，用于后续上传到七牛

使用方式：
  python3 cms-push-skill/scripts/skill-management/pack_skill.py <skill-dir> [--output <output.zip>]

参数说明：
  skill-dir     Skill 目录路径（必须）
  --output      输出 ZIP 文件路径（可选，默认为 <skill-name>.zip）

示例：
  python3 cms-push-skill/scripts/skill-management/pack_skill.py ./im-robot
  python3 cms-push-skill/scripts/skill-management/pack_skill.py ./im-robot --output ./dist/im-robot-v1.zip

说明：
  无需登录 token。
"""

import sys
import os
import argparse
import zipfile


def pack_skill(skill_dir: str, output_path: str, emit_stdout: bool = False) -> str:
    """将 Skill 目录打包为 ZIP"""
    skill_dir = os.path.abspath(skill_dir)
    if not os.path.isdir(skill_dir):
        raise FileNotFoundError(f"目录不存在: {skill_dir}")

    # 检查 SKILL.md 是否存在
    skill_md = os.path.join(skill_dir, "SKILL.md")
    if not os.path.isfile(skill_md):
        raise FileNotFoundError(f"不是有效的 Skill 目录（缺少 SKILL.md）: {skill_dir}")

    skill_name = os.path.basename(skill_dir)
    output_path = os.path.abspath(output_path)
    output_path_real = os.path.realpath(output_path)

    # 确保输出目录存在
    output_dir = os.path.dirname(output_path)
    if output_dir:
        os.makedirs(output_dir, exist_ok=True)

    skill_dir_real = os.path.realpath(skill_dir)
    file_count = 0
    with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
        for root, dirs, files in os.walk(skill_dir, followlinks=False):
            # 跳过隐藏目录和 __pycache__
            dirs[:] = [
                d for d in dirs
                if not d.startswith(".")
                and d != "__pycache__"
                and not os.path.islink(os.path.join(root, d))
            ]
            for f in files:
                if f.startswith(".") or f.endswith(".pyc"):
                    continue
                full_path = os.path.join(root, f)
                # 跳过 symlink，避免越界写入或泄漏外部文件
                if os.path.islink(full_path):
                    print(f"跳过软链接: {full_path}", file=sys.stderr)
                    continue
                # 防止把输出 ZIP 自身打包进去
                if os.path.realpath(full_path) == output_path_real:
                    continue
                # 确认仍在 skill_dir 内
                if not os.path.realpath(full_path).startswith(skill_dir_real + os.sep):
                    print(f"跳过越界文件: {full_path}", file=sys.stderr)
                    continue
                rel = os.path.relpath(full_path, skill_dir).replace(os.sep, "/")
                if rel.startswith("..") or "/../" in f"/{rel}/":
                    print(f"跳过越界相对路径: {rel}", file=sys.stderr)
                    continue
                arc_name = f"{skill_name}/{rel}"
                zf.write(full_path, arc_name)
                file_count += 1

    size_kb = os.path.getsize(output_path) / 1024
    print(f"打包完成: {output_path}", file=sys.stderr)
    print(f"文件数: {file_count}，大小: {size_kb:.1f} KB", file=sys.stderr)

    # 输出 ZIP 路径到 stdout（方便管道传给下一步）
    if emit_stdout:
        print(output_path)
    return output_path


def main():
    parser = argparse.ArgumentParser(description="打包 Skill 目录为 ZIP 文件")
    parser.add_argument("skill_dir", help="Skill 目录路径")
    parser.add_argument("--output", default="", help="输出 ZIP 文件路径")
    args = parser.parse_args()

    output = args.output
    if not output:
        skill_name = os.path.basename(os.path.abspath(args.skill_dir))
        output = f"{skill_name}.zip"

    pack_skill(args.skill_dir, output, emit_stdout=True)


if __name__ == "__main__":
    main()


path: scripts/skill-management/publish_skill.py
content: #!/usr/bin/env python3
"""
一站式发布 Skill（内部：打包 → 上传七牛 → 注册/更新；外部：直接注册/更新）

用途：
  将指定 Skill 目录一键完成发布到平台（ClawHub 协议格式）。
  - 内部模式：打包 ZIP → 上传到七牛 → 注册/更新
  - 外部模式：跳过七牛上传，直接使用 ClawHub 下载地址注册/更新

使用方式：
  # 首次发布（注册）
  python3 cms-push-skill/scripts/skill-management/publish_skill.py ./im-robot --code im-robot --name "IM 机器人"

  # 更新已有 Skill（加 --update）
  python3 cms-push-skill/scripts/skill-management/publish_skill.py ./im-robot --code im-robot --update [--name "新名称"] [--version 1.2.0]

  # 外部 Skill（ClawHub）发布
  python3 cms-push-skill/scripts/skill-management/publish_skill.py ./im-robot --code im-robot --name "IM 机器人" --external

参数说明：
  skill_dir       Skill 目录路径（必须）
  --code          Skill 唯一标识（必须）
  --name          Skill 显示名称（注册时必须，更新时可选）
  --description   Skill 描述
  --label         Skill 标签（逗号分隔）
  --version       版本号（semver 格式，如 1.2.0，默认 0.0.1）
  --update        更新模式（默认为注册模式）
  --output        ZIP 输出路径（可选）
  --file-key      七牛文件 key（可选，默认自动生成）
  --external      标记为外部 Skill（使用 ClawHub 下载地址）

环境变量：
  XG_USER_TOKEN  — access-token（必须）
"""

import sys
import os
import json
import time
import argparse
from urllib.parse import quote

# 导入同目录下的模块
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, script_dir)

from common import get_token
from pack_skill import pack_skill
from upload_to_qiniu import get_qiniu_token, upload_file
from register_skill import call_api as register_api
from update_skill import call_api as update_api

EXTERNAL_DOWNLOAD_URL_TEMPLATE = "https://wry-manatee-359.convex.site/api/v1/download?slug={}"


def build_external_download_url(skill_code: str) -> str:
    return EXTERNAL_DOWNLOAD_URL_TEMPLATE.format(quote(skill_code, safe=""))


def build_register_payload(args, download_url: str) -> dict:
    """构造 ClawHub 协议格式的注册 payload。"""
    tags = [t.strip() for t in args.label.split(",") if t.strip()] if args.label else []
    return {
        "name": args.code,
        "skillCode": args.code,
        "displayName": args.name,
        "version": args.version,
        "description": args.description,
        "downloadUrl": download_url,
        "metadata": {
            "openclaw": {"tags": tags},
        },
    }


def build_update_payload(args, download_url: str) -> dict:
    """构造 ClawHub 协议格式的更新 payload。"""
    tags = [t.strip() for t in args.label.split(",") if t.strip()] if args.label else []
    payload = {
        "skillCode": args.code,
        "downloadUrl": download_url,
        "metadata": {
            "openclaw": {"tags": tags},
        },
    }
    if args.name:
        payload["displayName"] = args.name
    if args.description:
        payload["description"] = args.description
    if args.version:
        payload["version"] = args.version
    return payload


def main():
    parser = argparse.ArgumentParser(description="一站式发布 Skill")
    parser.add_argument("skill_dir", help="Skill 目录路径")
    parser.add_argument("--code", required=True, help="Skill 唯一标识")
    parser.add_argument("--name", default="", help="Skill 显示名称（注册时必须）")
    parser.add_argument("--description", default="", help="Skill 描述")
    parser.add_argument("--label", default="", help="Skill 标签（逗号分隔）")
    parser.add_argument("--version", default="0.0.1", help="版本号（semver 格式，默认 0.0.1）")
    parser.add_argument("--update", action="store_true", help="更新模式（默认为注册模式）")
    parser.add_argument("--output", default="", help="ZIP 输出路径")
    parser.add_argument("--file-key", default="", help="七牛文件 key")
    parser.add_argument("--external", action="store_true", help="标记为外部 Skill（使用 ClawHub 下载地址）")
    parser.add_argument("--corp-id", default=os.environ.get("XG_CORP_ID", ""), help="企业 ID（用于获取七牛上传凭证）")
    args = parser.parse_args()

    [REDACTED_SECRET])

    is_external = args.external

    mode = "更新" if args.update else "注册"
    if not args.update and not args.name:
        print("错误: 注册模式下 --name 是必须的", file=sys.stderr)
        sys.exit(1)

    skill_name = os.path.basename(os.path.abspath(args.skill_dir))

    if not is_external:
        # ── Step 1: 打包 ──
        zip_output = args.output or f"{skill_name}.zip"
        print(f"\n{'='*50}", file=sys.stderr)
        print(f"[Step 1/3] 打包 Skill 目录 → ZIP", file=sys.stderr)
        print(f"{'='*50}", file=sys.stderr)
        zip_path = pack_skill(args.skill_dir, zip_output, emit_stdout=False)

        # ── Step 2: 上传七牛 ──
        file_key = args.file_key or f"skills/{args.code}/{int(time.time())}-{os.path.basename(zip_path)}"
        print(f"\n{'='*50}", file=sys.stderr)
        print(f"[Step 2/3] 上传到七牛 (fileKey={file_key})", file=sys.stderr)
        print(f"{'='*50}", file=sys.stderr)

        creds = get_qiniu_token(token, file_key, args.corp_id)
        qiniu_token = creds["token"]
        domain = creds["domain"]
        print(f"凭证获取成功，domain={domain}", file=sys.stderr)

        size_kb = os.path.getsize(zip_path) / 1024
        print(f"上传 {os.path.basename(zip_path)} ({size_kb:.1f} KB) ...", file=sys.stderr)
        upload_file(qiniu_token, file_key, zip_path)

        base_url = domain if domain.startswith("http") else f"https://{domain}"
        download_url = f"{base_url.rstrip('/')}/{file_key}"
        print(f"上传成功! 下载地址: {download_url}", file=sys.stderr)
    else:
        print(f"\n{'='*50}", file=sys.stderr)
        print(f"[Step 1/2] 外部 Skill 模式：跳过七牛上传", file=sys.stderr)
        print(f"{'='*50}", file=sys.stderr)
        download_url = build_external_download_url(args.code)
        print(f"外部 Skill 下载地址: {download_url}", file=sys.stderr)

    # ── Step 3: 注册/更新 ──
    print(f"\n{'='*50}", file=sys.stderr)
    print(f"[{3 if not is_external else 2}/{3 if not is_external else 2}] {mode} Skill (code={args.code})", file=sys.stderr)
    print(f"{'='*50}", file=sys.stderr)

    if args.update:
        payload = build_update_payload(args, download_url)
        result = update_api(token, payload)
    else:
        payload = build_register_payload(args, download_url)
        result = register_api(token, payload)

    print(f"\n{'='*50}", file=sys.stderr)
    print(f"✅ {mode}完成!", file=sys.stderr)
    print(f"  Skill: {args.code}", file=sys.stderr)
    print(f"  下载地址: {download_url}", file=sys.stderr)
    print(f"  后台同步: 平台接口会自动处理 ClawHub / GitHub 机器人同步", file=sys.stderr)
    print(f"{'='*50}", file=sys.stderr)

    # 输出完整结果到 stdout
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/skill-management/register_skill.py
content: #!/usr/bin/env python3
"""
发布（注册）新 Skill

用途：向平台注册一个新的 AI Skill（ClawHub 协议格式）

使用方式：
  python3 cms-push-skill/scripts/skill-management/register_skill.py --code <code> --name <name> [--description <desc>] [--download-url <url>] [--label <label>]

参数说明：
  --code          Skill 唯一标识（必须）
  --name          Skill 显示名称（必须）
  --description   Skill 描述
  --download-url  Skill 包下载地址
  --label         Skill 标签（逗号分隔）
  --version       版本号（semver 格式，如 1.0.0，默认 0.0.1）

环境变量：
  XG_USER_TOKEN  — access-token（必须）
"""

import sys
import json
import argparse
import requests

from common import API_BASE, get_headers, get_token, parse_api_response

API_URL = f"{API_BASE}/api/skill/register"


def call_api(token: str, payload: dict) -> dict:
    """注册新 Skill（ClawHub 格式）"""
    headers = get_headers(token)

    try:
        response = requests.post(
            API_URL,
            json=payload,
            headers=headers,
            verify=False,
            allow_redirects=True,
            timeout=60,
        )
        response.raise_for_status()
        return parse_api_response(response, "注册 Skill")
    except Exception as e:
        print(f"错误: 请求失败: {e}", file=sys.stderr)
        sys.exit(1)


def build_clawhub_payload(args) -> dict:
    """将 CLI 参数转换为 ClawHub 协议格式。"""
    tags = [t.strip() for t in args.label.split(",") if t.strip()] if args.label else []

    payload = {
        "name": args.code,
        "skillCode": args.code,
        "displayName": args.name,
        "version": args.version,
        "description": args.description,
        "downloadUrl": args.download_url,
        "metadata": {
            "openclaw": {
                "tags": tags,
            },
        },
    }
    return payload


def main():
    parser = argparse.ArgumentParser(description="发布（注册）新 Skill")
    parser.add_argument("--code", required=True, help="Skill 唯一标识")
    parser.add_argument("--name", required=True, help="Skill 显示名称")
    parser.add_argument("--description", default="", help="Skill 描述")
    parser.add_argument("--download-url", default="", help="Skill 包下载地址")
    parser.add_argument("--label", default="", help="Skill 标签（逗号分隔）")
    parser.add_argument("--version", default="0.0.1", help="版本号（semver，默认 0.0.1）")
    args = parser.parse_args()

    [REDACTED_SECRET])
    payload = build_clawhub_payload(args)
    result = call_api(token, payload)
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/skill-management/update_skill.py
content: #!/usr/bin/env python3
"""
更新已有 Skill

用途：更新已注册 Skill 的信息（名称、描述、下载地址等）（ClawHub 协议格式）

使用方式：
  python3 cms-push-skill/scripts/skill-management/update_skill.py --code <code> [--name <name>] [--description <desc>] [--download-url <url>] [--label <label>] [--version <ver>]

参数说明：
  --code          Skill 唯一标识（必须）
  --name          新的 Skill 显示名称
  --description   新的描述
  --download-url  新的下载地址
  --label         新的标签（逗号分隔）
  --version       版本号（semver 格式，如 1.2.0）

环境变量：
  XG_USER_TOKEN  — access-token（必须）
"""

import sys
import json
import argparse
import requests

from common import API_BASE, get_headers, get_token, parse_api_response

API_URL = f"{API_BASE}/api/skill/upgrade"


def call_api(token: str, payload: dict) -> dict:
    """更新 Skill 信息（ClawHub 格式）"""
    headers = get_headers(token)

    try:
        response = requests.post(
            API_URL,
            json=payload,
            headers=headers,
            verify=False,
            allow_redirects=True,
            timeout=60,
        )
        response.raise_for_status()
        return parse_api_response(response, "更新 Skill")
    except Exception as e:
        print(f"错误: 请求失败: {e}", file=sys.stderr)
        sys.exit(1)


def build_upgrade_payload(args) -> dict:
    """构造升级 Skill 的请求体 (ClawHubUpgradeRequest)"""
    payload = {
        "name": args.code,
        "skillCode": args.code,
    }

    if args.change_log:
        payload["changeLog"] = args.change_log
    if args.download_url:
        payload["downloadUrl"] = args.download_url
    if args.version:
        payload["version"] = args.version

    # 保留元数据支持（后端 /upgrade 接口目前主要处理版本升级，元数据由 /update 处理）
    if args.name:
        payload["displayName"] = args.name
    if args.description:
        payload["description"] = args.description
    
    if args.label:
        tags = [t.strip() for t in args.label.split(",") if t.strip()] if args.label else []
        payload["metadata"] = {
            "openclaw": {
                "tags": tags,
            },
        }

    return payload


def main():
    parser = argparse.ArgumentParser(description="更新已有 Skill")
    parser.add_argument("--code", required=True, help="Skill 唯一标识")
    parser.add_argument("--name", default="", help="新的 Skill 显示名称")
    parser.add_argument("--description", default="", help="新的描述")
    parser.add_argument("--download-url", default="", help="新的下载地址")
    parser.add_argument("--label", default="", help="新的标签（逗号分隔）")
    parser.add_argument("--version", default="", help="版本号（semver 格式，如 1.2.0）")
    parser.add_argument("--change-log", default="", help="升级说明 (可选)")
    args = parser.parse_args()

    [REDACTED_SECRET])
    payload = build_upgrade_payload(args)
    result = call_api(token, payload)
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/skill-management/upload_to_qiniu.py
content: #!/usr/bin/env python3
"""
上传文件到七牛云存储

用途：获取七牛上传凭证 → 上传文件 → 返回下载地址

使用方式：
  python3 cms-push-skill/scripts/skill-management/upload_to_qiniu.py <file-path> [--file-key <key>] [--corp-id <id>]

参数说明：
  file-path     要上传的文件路径（必须）
  --file-key    七牛文件 key（可选，默认为 时间戳-文件名）
  --corp-id     企业 ID（可选，也可通过环境变量 XG_CORP_ID 设置）

环境变量：
  XG_USER_TOKEN  — access-token（必须，用于获取七牛上传凭证）
  XG_CORP_ID     — 企业 ID（可选，也可通过 --corp-id 参数传入）

示例：
  python3 cms-push-skill/scripts/skill-management/upload_to_qiniu.py ./im-robot.zip
  python3 cms-push-skill/scripts/skill-management/upload_to_qiniu.py ./im-robot.zip --file-key "skills/im-robot-v1.zip"

输出：
  成功时输出下载地址到 stdout，可直接作为 register_skill.py 的 --download-url 参数。
"""

import sys
import os
import time
import argparse
import requests

from common import API_BASE, get_headers, get_token, parse_api_response

# 七牛上传凭证接口
QINIU_AUTH_URL = f"{API_BASE}/api/qiniu/token"

# 七牛上传地址（z2 区域）
QINIU_UPLOAD_URL = "https://up-z2.qiniup.com/"


def get_qiniu_token(access_token: str, file_key: str, corp_id: str = "") -> dict:
    """获取七牛上传凭证，返回 {token, domain}。"""
    headers = get_headers(access_token)
    params = {"fileKey": file_key, "corpId": corp_id}

    try:
        response = requests.post(
            QINIU_AUTH_URL,
            json=params,
            headers=headers,
            verify=False,
            allow_redirects=True,
            timeout=60,
        )
        response.raise_for_status()
        data = parse_api_response(response, "获取七牛凭证")
        if not data.get("data") or not data["data"].get("token") or not data["data"].get("domain"):
            raise RuntimeError(f"获取七牛凭证失败: {response.text}")
        return data["data"]
    except Exception as e:
        raise RuntimeError(f"获取七牛凭证失败: {e}")


def upload_file(qiniu_token: str, file_key: str, file_path: str, max_retries: int = 3) -> bool:
    """通过 multipart/form-data 上传文件到七牛，带指数退避重试。"""
    file_name = os.path.basename(file_path)

    def do_upload(url):
        with open(file_path, 'rb') as f:
            files = {'file': (file_name, f, 'application/octet-stream')}
            data = {'token': qiniu_token, 'key': file_key}
            return requests.post(
                url,
                files=files,
                data=data,
                verify=False,
                allow_redirects=True,
                timeout=300,
            )

    last_err = None
    for attempt in range(1, max_retries + 1):
        response = None
        try:
            response = do_upload(QINIU_UPLOAD_URL)

            # 处理七牛区域重定向 (400 错误且含有 "please use <host>")
            if response.status_code == 400 and "please use" in response.text:
                import re
                region_match = re.search(r'please use\s+([a-z0-9.-]+)', response.text, re.IGNORECASE)
                if region_match:
                    new_host = region_match.group(1)
                    new_url = f"https://{new_host}/"
                    print(f"区域重定向: {new_url}", file=sys.stderr)
                    response = do_upload(new_url)

            response.raise_for_status()
            return True
        except Exception as e:
            last_err = response.text if response is not None else str(e)
            if attempt < max_retries:
                backoff = 2 ** (attempt - 1)
                print(f"七牛上传第 {attempt} 次失败，{backoff}s 后重试: {last_err}", file=sys.stderr)
                time.sleep(backoff)
            else:
                raise RuntimeError(f"七牛上传失败（已重试 {max_retries} 次）: {last_err}")


def main():
    parser = argparse.ArgumentParser(description="上传文件到七牛云存储")
    parser.add_argument("file_path", help="要上传的文件路径")
    parser.add_argument("--file-key", default="", help="七牛文件 key（默认自动生成）")
    parser.add_argument("--corp-id", default="", help="企业 ID（也可通过 XG_CORP_ID 环境变量设置）")
    args = parser.parse_args()

    [REDACTED_SECRET])
    corp_id = os.environ.get("XG_CORP_ID", "")
    corp_id = args.corp_id or corp_id

    file_path = os.path.abspath(args.file_path)
    if not os.path.isfile(file_path):
        print(f"错误: 文件不存在: {file_path}", file=sys.stderr)
        sys.exit(1)

    file_name = os.path.basename(file_path)
    file_key = args.file_key or f"{int(time.time() * 1000)}-{file_name}"

    # Step 1: 获取七牛上传凭证
    print(f"[1/2] 获取七牛上传凭证 (fileKey={file_key}) ...", file=sys.stderr)
    creds = get_qiniu_token(token, file_key, corp_id)
    qiniu_token = creds["token"]
    domain = creds["domain"]
    print(f"[1/2] 凭证获取成功，domain={domain}", file=sys.stderr)

    # Step 2: 上传文件
    size_kb = os.path.getsize(file_path) / 1024
    print(f"[2/2] 上传 {file_name} ({size_kb:.1f} KB) ...", file=sys.stderr)
    upload_file(qiniu_token, file_key, file_path)

    # 构造下载地址
    base_url = domain if domain.startswith("http") else f"https://{domain}"
    base_url = base_url.rstrip("/")
    download_url = f"{base_url}/{file_key}"

    print(f"[2/2] 上传成功!", file=sys.stderr)
    print(f"下载地址: {download_url}", file=sys.stderr)

    # 输出下载地址到 stdout（方便管道）
    print(download_url)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)