Files
dingtalk-workspace-cli/internal/app/skill_command.go
T

378 lines
12 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
const (
// skillDownloadEndpoint is the API endpoint for downloading skills.
skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
// skillDownloadTimeout is the timeout for skill download operations.
skillDownloadTimeout = 5 * time.Minute
)
// downloadSkillResponse represents the API response for skill download.
type downloadSkillResponse struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result *downloadSkillResult `json:"result,omitempty"`
}
// downloadSkillResult contains the download URL and file name.
type downloadSkillResult struct {
DownloadURL string `json:"downloadUrl"`
FileName string `json:"fileName"`
}
// agentSkillPaths maps target names to their relative skill installation paths.
// These paths are relative to the user's home directory.
var agentSkillPaths = map[string]string{
"qoder": ".qoder/skills",
"claude": ".claude/skills",
"cursor": ".cursor/skills",
"codex": ".codex/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
}
// supportedTargets returns a comma-separated list of supported targets.
func supportedTargets() string {
targets := make([]string, 0, len(agentSkillPaths)+1)
for target := range agentSkillPaths {
targets = append(targets, target)
}
targets = append(targets, ".")
return strings.Join(targets, ", ")
}
func buildSkillCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "skill",
Short: "技能管理",
Long: "管理钉钉技能市场的技能。支持下载和安装技能到指定 Agent 目录。",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newSkillAddCommand())
return cmd
}
func newSkillAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add <skillId> <target>",
Short: "下载并安装技能到指定目录",
Long: fmt.Sprintf(`从钉钉技能市场下载技能并安装到指定 Agent 目录。
参数:
skillId 技能 ID(必填),可从钉钉技能市场获取
target 安装目标(必填),支持: %s
安装路径:
qoder -> ~/.qoder/skills/
claude -> ~/.claude/skills/
cursor -> ~/.cursor/skills/
codex -> ~/.codex/skills/
opencode -> ~/.config/opencode/skills/
. -> 当前目录
示例:
dws skill add skill-123 qoder # 安装到 ~/.qoder/skills/
dws skill add skill-123 claude # 安装到 ~/.claude/skills/
dws skill add skill-123 . # 安装到当前目录`, supportedTargets()),
Args: cobra.ExactArgs(2),
DisableAutoGenTag: true,
RunE: runSkillAdd,
}
return cmd
}
func runSkillAdd(cmd *cobra.Command, args []string) error {
skillID := strings.TrimSpace(args[0])
target := strings.TrimSpace(args[1])
if skillID == "" {
return apperrors.NewValidation("skillId is required")
}
// Resolve target path
destPath, err := resolveSkillTargetPath(target)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
}
// Load auth token
configDir := defaultConfigDir()
tokenData, err := authpkg.LoadTokenData(configDir)
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
return apperrors.NewAuth("not logged in or token expired. Please run 'dws auth login' first",
apperrors.WithHint("请先执行 'dws auth login' 登录"),
apperrors.WithActions("dws auth login"))
}
ctx, cancel := context.WithTimeout(cmd.Context(), skillDownloadTimeout)
defer cancel()
w := cmd.OutOrStdout()
// Step 1: Get download URL from API
fmt.Fprintf(w, "正在获取技能信息...\n")
downloadResp, err := fetchSkillDownloadInfo(ctx, tokenData.AccessToken, skillID)
if err != nil {
return err
}
if !downloadResp.Success {
errMsg := downloadResp.ErrorMsg
if errMsg == "" {
errMsg = downloadResp.ErrorCode
}
if errMsg == "" {
errMsg = "unknown error"
}
return apperrors.NewAPI(fmt.Sprintf("failed to get skill download info: %s", errMsg),
apperrors.WithReason(downloadResp.ErrorCode))
}
if downloadResp.Result == nil || downloadResp.Result.DownloadURL == "" {
return apperrors.NewAPI("skill download URL not found in response")
}
// Step 2: Download the skill zip file
fmt.Fprintf(w, "正在下载技能...\n")
tempZipPath, err := downloadSkillFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName)
if err != nil {
return err
}
defer cleanupTempFile(tempZipPath)
// Step 3: Extract zip to destination
fmt.Fprintf(w, "正在解压到 %s...\n", destPath)
if err := extractSkillZip(tempZipPath, destPath); err != nil {
return err
}
fmt.Fprintf(w, "\n[OK] 技能安装成功!\n")
fmt.Fprintf(w, "安装路径: %s\n", destPath)
return nil
}
// resolveSkillTargetPath resolves the target argument to an absolute path.
func resolveSkillTargetPath(target string) (string, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", fmt.Errorf("target is required")
}
// Special case: current directory
if target == "." {
return os.Getwd()
}
// Look up predefined agent paths
relPath, ok := agentSkillPaths[strings.ToLower(target)]
if !ok {
return "", fmt.Errorf("unsupported target")
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, relPath), nil
}
// fetchSkillDownloadInfo calls the download API to get the skill download URL.
func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*downloadSkillResponse, error) {
url := fmt.Sprintf("%s?skillId=%s", skillDownloadEndpoint, skillID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-user-access-token", accessToken)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, apperrors.NewAPI(fmt.Sprintf("failed to call download API: %v", err),
apperrors.WithRetryable(true))
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, apperrors.NewAuth("authentication failed. Please run 'dws auth login' to refresh your token",
apperrors.WithHint("请执行 'dws auth login' 重新登录"),
apperrors.WithActions("dws auth login"))
}
if resp.StatusCode != http.StatusOK {
return nil, apperrors.NewAPI(fmt.Sprintf("download API returned HTTP %d", resp.StatusCode),
apperrors.WithRetryable(resp.StatusCode >= 500))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit
if err != nil {
return nil, apperrors.NewAPI(fmt.Sprintf("failed to read response: %v", err))
}
var result downloadSkillResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, apperrors.NewAPI(fmt.Sprintf("failed to parse response: %v", err))
}
return &result, nil
}
// downloadSkillFile downloads the skill zip file to a temporary location.
func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to create download request: %v", err))
}
client := &http.Client{Timeout: skillDownloadTimeout}
resp, err := client.Do(req)
if err != nil {
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill: %v", err),
apperrors.WithRetryable(true))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", apperrors.NewAPI(fmt.Sprintf("download returned HTTP %d", resp.StatusCode),
apperrors.WithRetryable(resp.StatusCode >= 500))
}
// Create temp file
if fileName == "" {
fileName = "skill.zip"
}
tempFile, err := os.CreateTemp("", "dws-skill-*.zip")
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
}
tempPath := tempFile.Name()
// Copy response body to temp file
_, err = io.Copy(tempFile, resp.Body)
closeErr := tempFile.Close()
if err != nil {
os.Remove(tempPath)
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
}
if closeErr != nil {
os.Remove(tempPath)
return "", apperrors.NewInternal(fmt.Sprintf("failed to close temp file: %v", closeErr))
}
return tempPath, nil
}
// extractSkillZip extracts a zip file to the destination directory.
func extractSkillZip(zipPath, destDir string) error {
// Ensure destination directory exists
if err := os.MkdirAll(destDir, 0755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create destination directory: %v", err))
}
reader, err := zip.OpenReader(zipPath)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to open zip file: %v", err))
}
defer reader.Close()
for _, file := range reader.File {
if err := extractZipFile(file, destDir); err != nil {
return err
}
}
return nil
}
// extractZipFile extracts a single file from the zip archive.
func extractZipFile(file *zip.File, destDir string) error {
// Sanitize file path to prevent zip slip attacks
filePath := filepath.Join(destDir, file.Name)
if !strings.HasPrefix(filepath.Clean(filePath), filepath.Clean(destDir)+string(os.PathSeparator)) {
return apperrors.NewValidation(fmt.Sprintf("invalid file path in zip: %s", file.Name))
}
if file.FileInfo().IsDir() {
// Use 0755 to ensure we have write permission for creating files inside
return os.MkdirAll(filePath, 0755)
}
// Ensure parent directory exists with write permission
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
}
// Extract file
srcFile, err := file.Open()
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to open file in zip: %v", err))
}
defer srcFile.Close()
// Use file mode from zip but ensure at least 0644 for files
fileMode := file.Mode()
if fileMode&0600 == 0 {
fileMode = 0644
}
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create file: %v", err))
}
defer destFile.Close()
if _, err := io.Copy(destFile, srcFile); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to extract file: %v", err))
}
return nil
}
// cleanupTempFile removes a temporary file, ignoring errors.
func cleanupTempFile(path string) {
if path != "" {
os.Remove(path)
}
}