// 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 ` and // `dws skill setup --target `. 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 // " -> ~/" 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 ", 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 ", 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 ") 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) } }