826 lines
27 KiB
Go
826 lines
27 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"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"mime"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"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/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
var (
|
||
skillLoadAccessToken = loadSkillAccessToken
|
||
skillDownloadToTmp = downloadSkillToTmpDir
|
||
skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) }
|
||
skillNewRequest = http.NewRequestWithContext
|
||
skillResolveAccessToken = ResolveAuxiliaryAccessToken
|
||
skillResolveTargetPath = resolveSkillTargetPath
|
||
skillFetchDownloadInfo = fetchSkillDownloadInfo
|
||
skillDownloadFile = downloadSkillFile
|
||
skillExtractZip = extractSkillZip
|
||
skillUserHomeDir = os.UserHomeDir
|
||
skillMkdirTemp = os.MkdirTemp
|
||
skillCreate = os.Create
|
||
skillCreateTemp = os.CreateTemp
|
||
skillRemoveAll = os.RemoveAll
|
||
skillRemove = os.Remove
|
||
skillMkdirAll = os.MkdirAll
|
||
skillOpenFile = os.OpenFile
|
||
skillCopy = io.Copy
|
||
skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() }
|
||
)
|
||
|
||
func init() {
|
||
configmeta.Register(configmeta.ConfigItem{
|
||
Name: "DWS_SKILL_API_HOST",
|
||
Category: configmeta.CategoryNetwork,
|
||
Description: "覆盖 Skill API 地址",
|
||
DefaultValue: "https://mcp.dingtalk.com",
|
||
Example: "https://custom-mcp.example.com",
|
||
})
|
||
}
|
||
|
||
const (
|
||
// legacySkillAPIHost is the legacy skill market host used by the old cli.
|
||
legacySkillAPIHost = "https://mcp.dingtalk.com"
|
||
// skillDownloadTimeout is the timeout for skill download operations.
|
||
skillDownloadTimeout = 5 * time.Minute
|
||
)
|
||
|
||
// skillDownloadEndpoint is variable so tests and private distributions can
|
||
// exercise the download flow without contacting the public service.
|
||
var skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// findSkillsResponse represents the legacy skill search API response.
|
||
type findSkillsResponse struct {
|
||
Success bool `json:"success"`
|
||
ErrorCode string `json:"errorCode,omitempty"`
|
||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||
Result []CliSkillDTO `json:"result,omitempty"`
|
||
}
|
||
|
||
// CliSkillDTO mirrors the old cli response payload for `skill search`.
|
||
type CliSkillDTO struct {
|
||
SkillID string `json:"skillId"`
|
||
Name string `json:"name"`
|
||
Desc string `json:"desc"`
|
||
Icon string `json:"icon"`
|
||
}
|
||
|
||
// agentSkillPaths maps target names to their relative skill installation paths.
|
||
// These paths are relative to the user's home directory.
|
||
//
|
||
// Source of truth for both `dws skill install <skillId> <target>` and
|
||
// `dws skill setup --target <name>`. Every entry in skillSetupAgentHomes
|
||
// (skill_setup.go) MUST have a matching path value here — enforced by
|
||
// TestAgentSkillPathsCoversSetupHomes.
|
||
var agentSkillPaths = map[string]string{
|
||
// Universal agents. Those without an independent global directory map
|
||
// directly to the canonical ~/.agents/skills store.
|
||
"agents": ".agents/skills",
|
||
"amp": filepath.Join(".config", "agents", "skills"),
|
||
"antigravity": ".gemini/antigravity/skills",
|
||
"antigravity-cli": ".gemini/antigravity-cli/skills",
|
||
"codex": ".codex/skills",
|
||
"cursor": ".cursor/skills",
|
||
"deepagents": ".deepagents/agent/skills",
|
||
"firebender": ".firebender/skills",
|
||
"gemini-cli": ".gemini/skills",
|
||
"github-copilot": ".copilot/skills",
|
||
"opencode": filepath.Join(".config", "opencode", "skills"),
|
||
"replit": filepath.Join(".config", "agents", "skills"),
|
||
"universal": filepath.Join(".config", "agents", "skills"),
|
||
"cline": ".agents/skills",
|
||
"dexto": ".agents/skills",
|
||
"kimi-code-cli": ".agents/skills",
|
||
"loaf": ".agents/skills",
|
||
"warp": ".agents/skills",
|
||
"zed": ".agents/skills",
|
||
|
||
// Non-universal agents with global Skill directories.
|
||
"aider-desk": ".aider-desk/skills",
|
||
"astrbot": ".astrbot/data/skills",
|
||
"autohand-code": ".autohand/skills",
|
||
"augment": ".augment/skills",
|
||
"bob": ".bob/skills",
|
||
"claude-code": ".claude/skills",
|
||
"openclaw": ".openclaw/skills",
|
||
"codearts-agent": ".codeartsdoer/skills",
|
||
"codebuddy": ".codebuddy/skills",
|
||
"codemaker": ".codemaker/skills",
|
||
"codestudio": ".codestudio/skills",
|
||
"command-code": ".commandcode/skills",
|
||
"continue": ".continue/skills",
|
||
"cortex": ".snowflake/cortex/skills",
|
||
"crush": filepath.Join(".config", "crush", "skills"),
|
||
"devin": filepath.Join(".config", "devin", "skills"),
|
||
"droid": ".factory/skills",
|
||
"forgecode": ".forge/skills",
|
||
"goose": filepath.Join(".config", "goose", "skills"),
|
||
"grok": ".grok/skills",
|
||
"hermes-agent": ".hermes/skills",
|
||
"inference-sh": ".inferencesh/skills",
|
||
"jazz": ".jazz/skills",
|
||
"junie": ".junie/skills",
|
||
"iflow-cli": ".iflow/skills",
|
||
"kilo": ".kilocode/skills",
|
||
"kimchi": filepath.Join(".config", "kimchi", "harness", "skills"),
|
||
"kiro-cli": ".kiro/skills",
|
||
"kode": ".kode/skills",
|
||
"lingma": ".lingma/skills",
|
||
"mcpjam": ".mcpjam/skills",
|
||
"minimax-code": ".minimax/skills",
|
||
"mistral-vibe": ".vibe/skills",
|
||
"moxby": ".moxby/skills",
|
||
"mux": ".mux/skills",
|
||
"openhands": ".openhands/skills",
|
||
"ona": ".ona/skills",
|
||
"pi": ".pi/agent/skills",
|
||
"qoder": ".qoder/skills",
|
||
"qoder-cn": ".qoder-cn/skills",
|
||
"qwen-code": ".qwen/skills",
|
||
"reasonix": ".reasonix/skills",
|
||
"rovodev": ".rovodev/skills",
|
||
"roo": ".roo/skills",
|
||
"tabnine-cli": ".tabnine/agent/skills",
|
||
"terramind": ".terramind/skills",
|
||
"tinycloud": ".tinycloud/skills",
|
||
"trae": ".trae/skills",
|
||
"trae-cn": ".trae-cn/skills",
|
||
"windsurf": ".codeium/windsurf/skills",
|
||
"zcode": ".zcode/skills",
|
||
"zencoder": ".zencoder/skills",
|
||
"zenflow": ".zencoder/skills",
|
||
"neovate": ".neovate/skills",
|
||
"pochi": ".pochi/skills",
|
||
"adal": ".adal/skills",
|
||
|
||
// DWS compatibility aliases and DWS-only integrations.
|
||
"claude": ".claude/skills",
|
||
"gemini": ".gemini/skills",
|
||
"github": ".copilot/skills",
|
||
"hermes": ".hermes/skills",
|
||
"kiro": ".kiro/skills",
|
||
"qoderwork": ".qoderwork/skills",
|
||
}
|
||
|
||
// Eve has project-scoped Skill directories but no upstream globalSkillsDir.
|
||
// Keep it in the advertised enumeration while failing explicitly instead of
|
||
// pretending that a global install configured Eve.
|
||
var unsupportedGlobalAgentTargets = map[string]string{
|
||
"eve": "Eve 不支持全局 Skill 安装,请在 Eve 项目内配置 agent/skills",
|
||
"promptscript": "PromptScript 不支持全局 Skill 安装,请在项目内使用 .agents/skills",
|
||
}
|
||
|
||
// supportedTargets returns a sorted, comma-separated list of supported
|
||
// targets. Sorted so help text and error messages stay stable across runs
|
||
// (Go map iteration order is intentionally randomized).
|
||
func supportedTargets() string {
|
||
targets := make([]string, 0, len(agentSkillPaths)+len(unsupportedGlobalAgentTargets)+1)
|
||
for target := range agentSkillPaths {
|
||
targets = append(targets, target)
|
||
}
|
||
for target := range unsupportedGlobalAgentTargets {
|
||
targets = append(targets, target)
|
||
}
|
||
sort.Strings(targets)
|
||
targets = append(targets, ".")
|
||
return strings.Join(targets, ", ")
|
||
}
|
||
|
||
// longestAgentTargetName returns the character count of the longest target
|
||
// name in agentSkillPaths. Used by --help formatting to keep the "." entry
|
||
// vertically aligned with named targets.
|
||
func longestAgentTargetName() int {
|
||
n := 0
|
||
for name := range agentSkillPaths {
|
||
if len(name) > n {
|
||
n = len(name)
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// formatAgentSkillPathsForHelp renders agentSkillPaths as an aligned
|
||
// " <name> -> ~/<path>" block, sorted by name, for use in --help output.
|
||
// Keeps `dws skill install --help` in sync with the map without hand-edits.
|
||
func formatAgentSkillPathsForHelp() string {
|
||
names := make([]string, 0, len(agentSkillPaths))
|
||
maxWidth := 0
|
||
for n := range agentSkillPaths {
|
||
names = append(names, n)
|
||
if len(n) > maxWidth {
|
||
maxWidth = len(n)
|
||
}
|
||
}
|
||
sort.Strings(names)
|
||
var b strings.Builder
|
||
for _, n := range names {
|
||
installPath := agentSkillPaths[n]
|
||
if isUniversalSkillInstallTarget(n) {
|
||
installPath = ".agents/skills"
|
||
}
|
||
fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, installPath)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// Universal Agents discover the shared ~/.agents/skills store directly. A
|
||
// marketplace install addressed to one of those Agent IDs must therefore
|
||
// publish to canonical instead of recreating an Agent-private duplicate.
|
||
func isUniversalSkillInstallTarget(target string) bool {
|
||
rel, ok := agentSkillPaths[target]
|
||
if !ok {
|
||
return false
|
||
}
|
||
base := filepath.Join("__home__", rel)
|
||
canonical := filepath.Join("__home__", ".agents", "skills")
|
||
return sameSkillSetupPath(base, canonical) || isUniversalSkillSetupBase(base)
|
||
}
|
||
|
||
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(
|
||
newSkillInstallCommand(),
|
||
newSkillGetCommand(),
|
||
newSkillSearchCommand(),
|
||
newSkillFindHintCommand(),
|
||
newSkillAddHintCommand(),
|
||
newSkillSetupCommand(),
|
||
)
|
||
return cmd
|
||
}
|
||
|
||
func newSkillGetCommand() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "get",
|
||
Short: "获取技能压缩文件",
|
||
Long: "从服务端下载技能包到本地临时目录。命令执行成功后会输出临时目录路径,供调用方使用。",
|
||
Example: " dws skill get --skill-id <skillId>",
|
||
DisableAutoGenTag: true,
|
||
RunE: runSkillGet,
|
||
}
|
||
cmd.Flags().String("skill-id", "", "技能 ID(必填)")
|
||
_ = cmd.MarkFlagRequired("skill-id")
|
||
return cmd
|
||
}
|
||
|
||
func newSkillSearchCommand() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "search",
|
||
Short: "从钉钉技能市场搜索技能",
|
||
Long: "从钉钉技能市场搜索技能,根据关键词返回匹配的技能列表。",
|
||
Example: " dws skill search --query 关键词",
|
||
DisableAutoGenTag: true,
|
||
RunE: runSkillFind,
|
||
}
|
||
cmd.Flags().String("query", "", "搜索关键词(必填)")
|
||
_ = cmd.MarkFlagRequired("query")
|
||
cmd.Flags().String("source", "", "查询范围,空格分隔。备选值:DingtalkMarket(钉钉市场)、OrgInternal(企业内部)")
|
||
cmd.Flags().String("scopes", "", "查询范围(已废弃,请使用 --source)")
|
||
_ = cmd.Flags().MarkDeprecated("scopes", "请使用 --source 替代")
|
||
return cmd
|
||
}
|
||
|
||
func newSkillFindHintCommand() *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "find",
|
||
Short: "兼容旧用法,提示使用 skill search",
|
||
Hidden: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "use: dws skill search --query <关键词>")
|
||
return nil
|
||
},
|
||
}
|
||
}
|
||
|
||
func newSkillInstallCommand() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "install <skillId> <target>",
|
||
Short: "下载并安装技能到指定目录",
|
||
Long: fmt.Sprintf(`从钉钉技能市场下载技能并安装到指定 Agent 目录。
|
||
|
||
参数:
|
||
skillId 技能 ID(必填),可从钉钉技能市场获取
|
||
target 安装目标(必填),支持: %s
|
||
|
||
安装路径:
|
||
%s .%s -> 当前目录
|
||
|
||
示例:
|
||
dws skill install skill-123 claude # 安装到 ~/.claude/skills/
|
||
dws skill install skill-123 qoder # 安装到 ~/.qoder/skills/
|
||
dws skill install skill-123 . # 安装到当前目录`,
|
||
supportedTargets(),
|
||
formatAgentSkillPathsForHelp(),
|
||
strings.Repeat(" ", longestAgentTargetName()-1)),
|
||
Args: cobra.ExactArgs(2),
|
||
DisableAutoGenTag: true,
|
||
RunE: runSkillAdd,
|
||
}
|
||
|
||
return cmd
|
||
}
|
||
|
||
func newSkillAddHintCommand() *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "add",
|
||
Short: "兼容旧用法,提示使用 skill install",
|
||
Hidden: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "use: dws skill install <skillId> <target>")
|
||
return nil
|
||
},
|
||
}
|
||
}
|
||
|
||
func runSkillGet(cmd *cobra.Command, args []string) error {
|
||
skillID, _ := cmd.Flags().GetString("skill-id")
|
||
accessToken, err := skillLoadAccessToken(cmd.Context())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
apiURL := fmt.Sprintf("%s/cli/install?skillId=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(skillID)))
|
||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "⬇️ 下载技能包...")
|
||
|
||
tmpDir, err := skillDownloadToTmp(cmd.Context(), apiURL, accessToken)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), tmpDir)
|
||
return nil
|
||
}
|
||
|
||
func runSkillFind(cmd *cobra.Command, args []string) error {
|
||
keyword, _ := cmd.Flags().GetString("query")
|
||
source, _ := cmd.Flags().GetString("source")
|
||
if source == "" {
|
||
source, _ = cmd.Flags().GetString("scopes")
|
||
}
|
||
accessToken, err := skillLoadAccessToken(cmd.Context())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
apiURL := fmt.Sprintf("%s/cli/find-skills?keyword=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(keyword)))
|
||
if source != "" {
|
||
apiURL += "&source=" + url.QueryEscape(source)
|
||
}
|
||
req, err := skillNewRequest(cmd.Context(), http.MethodGet, apiURL, nil)
|
||
if err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||
}
|
||
req.Header.Set("x-user-access-token", accessToken)
|
||
|
||
client := &http.Client{Timeout: 30 * time.Second}
|
||
resp, err := skillHTTPDo(client, req)
|
||
if err != nil {
|
||
return apperrors.NewAPI(fmt.Sprintf("failed to search skills: %v", err), apperrors.WithRetryable(true))
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return parseLegacySkillAPIError(resp)
|
||
}
|
||
|
||
var result findSkillsResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return apperrors.NewAPI(fmt.Sprintf("failed to parse search response: %v", err))
|
||
}
|
||
if !result.Success {
|
||
errMsg := strings.TrimSpace(result.ErrorMsg)
|
||
if errMsg == "" {
|
||
errMsg = strings.TrimSpace(result.ErrorCode)
|
||
}
|
||
if errMsg == "" {
|
||
errMsg = "unknown error"
|
||
}
|
||
return apperrors.NewAPI(fmt.Sprintf("failed to search skills: %s", errMsg))
|
||
}
|
||
|
||
if len(result.Result) == 0 {
|
||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "未找到匹配的技能")
|
||
return nil
|
||
}
|
||
|
||
for _, skill := range result.Result {
|
||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "SkillID: %s\n", skill.SkillID)
|
||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Name: %s\n", skill.Name)
|
||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Desc: %s\n", skill.Desc)
|
||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "---")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
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 := skillResolveTargetPath(target)
|
||
if err != nil {
|
||
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
|
||
}
|
||
|
||
accessToken, err := skillLoadAccessToken(cmd.Context())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
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 := skillFetchDownloadInfo(ctx, 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 := skillDownloadFile(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 := skillExtractZip(tempZipPath, destPath); err != nil {
|
||
return err
|
||
}
|
||
|
||
fmt.Fprintf(w, "\n[OK] 技能安装成功!\n")
|
||
fmt.Fprintf(w, "安装路径: %s\n", destPath)
|
||
|
||
return nil
|
||
}
|
||
|
||
func loadSkillAccessToken(ctx context.Context) (string, error) {
|
||
configDir := defaultConfigDir()
|
||
token, err := skillResolveAccessToken(ctx, configDir, "")
|
||
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||
return "", skillAuthError()
|
||
}
|
||
if err != nil {
|
||
return "", fmt.Errorf("resolve skill access token: %w", err)
|
||
}
|
||
return token, nil
|
||
}
|
||
|
||
func skillAuthError() error {
|
||
if edition.Get().IsEmbedded {
|
||
return apperrors.NewAuth("认证信息已失效",
|
||
apperrors.WithReason("not_authenticated"),
|
||
apperrors.WithHint("请先完成钉钉账号登录后重试"))
|
||
}
|
||
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"))
|
||
}
|
||
|
||
func skillAPIHost() string {
|
||
if override := strings.TrimSpace(os.Getenv("DWS_SKILL_API_HOST")); override != "" {
|
||
return strings.TrimRight(override, "/")
|
||
}
|
||
return legacySkillAPIHost
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
|
||
target = strings.ToLower(target)
|
||
if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported {
|
||
return "", errors.New(reason)
|
||
}
|
||
|
||
// Look up predefined agent paths.
|
||
_, ok := agentSkillPaths[target]
|
||
if !ok {
|
||
return "", fmt.Errorf("unsupported target")
|
||
}
|
||
|
||
homeDir, err := skillUserHomeDir()
|
||
if err != nil {
|
||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||
}
|
||
|
||
destination := resolveSkillSetupBase(homeDir, target)
|
||
if isUniversalSkillInstallTarget(target) {
|
||
destination = filepath.Join(homeDir, ".agents", "skills")
|
||
}
|
||
return destination, 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 := skillNewRequest(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 := skillHTTPDo(client, 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, skillAuthError()
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func downloadSkillToTmpDir(ctx context.Context, apiURL, accessToken string) (string, error) {
|
||
req, err := skillNewRequest(ctx, http.MethodGet, apiURL, nil)
|
||
if err != nil {
|
||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||
}
|
||
req.Header.Set("x-user-access-token", accessToken)
|
||
|
||
client := &http.Client{Timeout: skillDownloadTimeout}
|
||
resp, err := skillHTTPDo(client, req)
|
||
if err != nil {
|
||
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill package: %v", err), apperrors.WithRetryable(true))
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return "", parseLegacySkillAPIError(resp)
|
||
}
|
||
|
||
tmpDir, err := skillMkdirTemp("", "dws-skill-*")
|
||
if err != nil {
|
||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp dir: %v", err))
|
||
}
|
||
|
||
filename := filenameFromDisposition(resp.Header.Get("Content-Disposition"))
|
||
destPath := filepath.Join(tmpDir, filename)
|
||
file, err := skillCreate(destPath)
|
||
if err != nil {
|
||
_ = skillRemoveAll(tmpDir)
|
||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
|
||
}
|
||
defer file.Close()
|
||
|
||
if _, err := skillCopy(file, resp.Body); err != nil {
|
||
_ = skillRemoveAll(tmpDir)
|
||
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
|
||
}
|
||
return tmpDir, nil
|
||
}
|
||
|
||
func filenameFromDisposition(cd string) string {
|
||
if cd != "" {
|
||
if _, params, err := mime.ParseMediaType(cd); err == nil {
|
||
if name := strings.TrimSpace(params["filename"]); name != "" {
|
||
return name
|
||
}
|
||
}
|
||
}
|
||
return "skill.zip"
|
||
}
|
||
|
||
func parseLegacySkillAPIError(resp *http.Response) error {
|
||
switch resp.StatusCode {
|
||
case http.StatusUnauthorized:
|
||
return skillAuthError()
|
||
case http.StatusBadRequest:
|
||
return apperrors.NewValidation("request parameters are invalid")
|
||
case http.StatusNotFound:
|
||
return apperrors.NewValidation("skill does not exist or corresponding file was not found")
|
||
default:
|
||
return apperrors.NewAPI(fmt.Sprintf("skill API returned HTTP %d", resp.StatusCode),
|
||
apperrors.WithRetryable(resp.StatusCode >= 500))
|
||
}
|
||
}
|
||
|
||
// 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 := skillHTTPDo(client, 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 := skillCreateTemp("", "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 = skillCopy(tempFile, resp.Body)
|
||
closeErr := tempFile.Close()
|
||
if err != nil {
|
||
_ = skillRemove(tempPath)
|
||
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
|
||
}
|
||
if closeErr != nil {
|
||
_ = skillRemove(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 := skillMkdirAll(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 skillMkdirAll(filePath, 0755)
|
||
}
|
||
|
||
// Ensure parent directory exists with write permission
|
||
if err := skillMkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
|
||
}
|
||
|
||
// Extract file
|
||
srcFile, err := skillOpenZipFile(file)
|
||
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 := skillOpenFile(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 := skillCopy(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 != "" {
|
||
_ = skillRemove(path)
|
||
}
|
||
}
|