diff --git a/.github/workflows/issue-to-AI-table.yml b/.github/workflows/issue-to-AI-table.yml new file mode 100644 index 00000000..8e766d5b --- /dev/null +++ b/.github/workflows/issue-to-AI-table.yml @@ -0,0 +1,52 @@ +# Issue 变更推送到 Webhook +# 当有 Issue 变更时,发送指定格式的数据到 webhook +name: 📤 Issue Webhook Notification + +on: + issues: + types: [opened, reopened, closed, edited, labeled, unlabeled] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: 📬 Send Issue to Webhook + uses: actions/github-script@v7 + with: + script: | + const webhook = process.env.ISSUE_WEBHOOK_URL; + if (!webhook) { + console.log('⚠️ ISSUE_WEBHOOK_URL not set, skipping notification'); + return; + } + + const payload = context.payload; + const issue = payload.issue; + const action = payload.action; + + // 构建指定格式的数据 + const webhookPayload = { + action: action, + issue: { + id: issue.id, + number: issue.number, + title: issue.title, + body: issue.body, + state: issue.state, + html_url: issue.html_url + } + }; + + const response = await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(webhookPayload) + }); + + if (response.ok) { + console.log('✅ Webhook notification sent successfully'); + } else { + console.log('❌ Failed to send webhook notification:', response.status, response.statusText); + } + env: + ISSUE_WEBHOOK_URL: ${{ secrets.DINGTALK_AI_TABLE_WEBHOOK }} diff --git a/.github/workflows/issue-to-dingtalk.yml b/.github/workflows/issue-to-dingtalk.yml new file mode 100644 index 00000000..09344730 --- /dev/null +++ b/.github/workflows/issue-to-dingtalk.yml @@ -0,0 +1,49 @@ +# Issue 自动同步到钉钉群 +# 当有新 Issue 时,自动发送到钉钉群(不包括 comment) +name: 🔔 Issue Notification to DingTalk + +on: + issues: + types: [opened, reopened, closed, labeled] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: 📬 Send Issue to DingTalk + uses: actions/github-script@v7 + with: + script: | + const webhook = process.env.DINGTALK_WEBHOOK; + if (!webhook) { + console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification'); + return; + } + + const payload = context.payload; + const issue = payload.issue; + const action = payload.action; + + // 构建消息标题和内容(确保包含关键字 "issue" 以支持 Custom Keywords 模式) + const title = `[${action.toUpperCase()}] Issue #${issue.number}: ${issue.title}`; + const content = issue.body?.substring(0, 500) || 'No description'; + const url = issue.html_url; + + // 消息内容必须包含关键字(如 "issue")以支持 Custom Keywords 安全模式 + const message = { + msgtype: 'markdown', + markdown: { + title: 'GitHub Issue 通知', + text: `## 🔔 GitHub Issue 通知\n\n**${title}**\n\n${content}${content.length >= 500 ? '...' : ''}\n\n[点击查看详情](${url})\n\n---\n📦 ${context.repo.owner}/${context.repo.repo}\n\n**关键词**: issue` + } + }; + + await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(message) + }); + + console.log('✅ DingTalk notification sent'); + env: + DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }} diff --git a/internal/app/root.go b/internal/app/root.go index 99f0d372..c6efb026 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -250,6 +250,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) utilityCommands := []*cobra.Command{ newAuthCommand(), + newSkillCommand(), newCacheCommand(), newCompletionCommand(root), newRecoveryCommand(rootCtx, loader, flags), @@ -275,6 +276,10 @@ func newAuthCommand() *cobra.Command { return buildAuthCommand() } +func newSkillCommand() *cobra.Command { + return buildSkillCommand() +} + func newCacheCommand() *cobra.Command { cacheCmd := newPlaceholderParent("cache", "缓存管理") diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go new file mode 100644 index 00000000..4a20cbb5 --- /dev/null +++ b/internal/app/skill_command.go @@ -0,0 +1,377 @@ +// 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 ", + 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) + } +} diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go new file mode 100644 index 00000000..f737d4a1 --- /dev/null +++ b/internal/app/skill_command_test.go @@ -0,0 +1,739 @@ +// 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" + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" +) + +func TestResolveSkillTargetPath(t *testing.T) { + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to get home directory: %v", err) + } + + tests := []struct { + name string + target string + wantSuffix string + wantErr bool + }{ + { + name: "qoder target", + target: "qoder", + wantSuffix: filepath.Join(".qoder", "skills"), + wantErr: false, + }, + { + name: "claude target", + target: "claude", + wantSuffix: filepath.Join(".claude", "skills"), + wantErr: false, + }, + { + name: "cursor target", + target: "cursor", + wantSuffix: filepath.Join(".cursor", "skills"), + wantErr: false, + }, + { + name: "codex target", + target: "codex", + wantSuffix: filepath.Join(".codex", "skills"), + wantErr: false, + }, + { + name: "opencode target", + target: "opencode", + wantSuffix: filepath.Join(".config", "opencode", "skills"), + wantErr: false, + }, + { + name: "case insensitive - QODER", + target: "QODER", + wantSuffix: filepath.Join(".qoder", "skills"), + wantErr: false, + }, + { + name: "case insensitive - Claude", + target: "Claude", + wantSuffix: filepath.Join(".claude", "skills"), + wantErr: false, + }, + { + name: "invalid target", + target: "invalid", + wantErr: true, + }, + { + name: "empty target", + target: "", + wantErr: true, + }, + { + name: "whitespace only", + target: " ", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveSkillTargetPath(tt.target) + if (err != nil) != tt.wantErr { + t.Errorf("resolveSkillTargetPath() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + expected := filepath.Join(homeDir, tt.wantSuffix) + if got != expected { + t.Errorf("resolveSkillTargetPath() = %v, want %v", got, expected) + } + } + }) + } +} + +func TestResolveSkillTargetPathCurrentDir(t *testing.T) { + // Test "." target returns current working directory + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + + got, err := resolveSkillTargetPath(".") + if err != nil { + t.Errorf("resolveSkillTargetPath(\".\") error = %v", err) + return + } + + if got != cwd { + t.Errorf("resolveSkillTargetPath(\".\") = %v, want %v", got, cwd) + } +} + +func TestParseDownloadSkillResponse(t *testing.T) { + tests := []struct { + name string + jsonInput string + wantSuccess bool + wantURL string + wantFile string + wantErrCode string + wantErrMsg string + }{ + { + name: "successful response", + jsonInput: `{ + "success": true, + "result": { + "downloadUrl": "https://example.com/skill.zip", + "fileName": "my-skill.zip" + } + }`, + wantSuccess: true, + wantURL: "https://example.com/skill.zip", + wantFile: "my-skill.zip", + }, + { + name: "error response", + jsonInput: `{ + "success": false, + "errorCode": "SKILL_NOT_FOUND", + "errorMsg": "The skill does not exist" + }`, + wantSuccess: false, + wantErrCode: "SKILL_NOT_FOUND", + wantErrMsg: "The skill does not exist", + }, + { + name: "success with empty result", + jsonInput: `{ + "success": true, + "result": null + }`, + wantSuccess: true, + wantURL: "", + wantFile: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var resp downloadSkillResponse + if err := json.Unmarshal([]byte(tt.jsonInput), &resp); err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + + if resp.Success != tt.wantSuccess { + t.Errorf("Success = %v, want %v", resp.Success, tt.wantSuccess) + } + + if tt.wantSuccess && resp.Result != nil { + if resp.Result.DownloadURL != tt.wantURL { + t.Errorf("DownloadURL = %v, want %v", resp.Result.DownloadURL, tt.wantURL) + } + if resp.Result.FileName != tt.wantFile { + t.Errorf("FileName = %v, want %v", resp.Result.FileName, tt.wantFile) + } + } + + if !tt.wantSuccess { + if resp.ErrorCode != tt.wantErrCode { + t.Errorf("ErrorCode = %v, want %v", resp.ErrorCode, tt.wantErrCode) + } + if resp.ErrorMsg != tt.wantErrMsg { + t.Errorf("ErrorMsg = %v, want %v", resp.ErrorMsg, tt.wantErrMsg) + } + } + }) + } +} + +func TestExtractSkillZip(t *testing.T) { + // Create a temporary zip file with test content + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "extracted") + + // Create zip file with test content + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("failed to create zip file: %v", err) + } + + zipWriter := zip.NewWriter(zipFile) + + // Add a file to the zip + fileContent := []byte("test content") + writer, err := zipWriter.Create("test-file.txt") + if err != nil { + t.Fatalf("failed to create file in zip: %v", err) + } + if _, err := writer.Write(fileContent); err != nil { + t.Fatalf("failed to write file content: %v", err) + } + + // Add a subdirectory with a file + writer, err = zipWriter.Create("subdir/nested-file.txt") + if err != nil { + t.Fatalf("failed to create nested file in zip: %v", err) + } + if _, err := writer.Write([]byte("nested content")); err != nil { + t.Fatalf("failed to write nested file content: %v", err) + } + + if err := zipWriter.Close(); err != nil { + t.Fatalf("failed to close zip writer: %v", err) + } + if err := zipFile.Close(); err != nil { + t.Fatalf("failed to close zip file: %v", err) + } + + // Extract the zip + if err := extractSkillZip(zipPath, destDir); err != nil { + t.Fatalf("extractSkillZip() error = %v", err) + } + + // Verify extracted files + extractedFile := filepath.Join(destDir, "test-file.txt") + content, err := os.ReadFile(extractedFile) + if err != nil { + t.Errorf("failed to read extracted file: %v", err) + } + if string(content) != "test content" { + t.Errorf("extracted content = %v, want %v", string(content), "test content") + } + + // Verify nested file + nestedFile := filepath.Join(destDir, "subdir", "nested-file.txt") + content, err = os.ReadFile(nestedFile) + if err != nil { + t.Errorf("failed to read nested file: %v", err) + } + if string(content) != "nested content" { + t.Errorf("nested content = %v, want %v", string(content), "nested content") + } +} + +func TestExtractSkillZipPreventZipSlip(t *testing.T) { + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "malicious.zip") + destDir := filepath.Join(tempDir, "extracted") + + // Create a zip file with a path traversal attempt + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("failed to create zip file: %v", err) + } + + zipWriter := zip.NewWriter(zipFile) + + // Try to create a file with path traversal + writer, err := zipWriter.Create("../../../etc/passwd") + if err != nil { + t.Fatalf("failed to create malicious file in zip: %v", err) + } + if _, err := writer.Write([]byte("malicious content")); err != nil { + t.Fatalf("failed to write malicious content: %v", err) + } + + if err := zipWriter.Close(); err != nil { + t.Fatalf("failed to close zip writer: %v", err) + } + if err := zipFile.Close(); err != nil { + t.Fatalf("failed to close zip file: %v", err) + } + + // Extract should fail due to zip slip protection + err = extractSkillZip(zipPath, destDir) + if err == nil { + t.Error("extractSkillZip() should have failed for zip slip attack") + } + if !strings.Contains(err.Error(), "invalid file path") { + t.Errorf("error should mention invalid file path, got: %v", err) + } +} + +func TestSkillAddCommandValidation(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + errMsg string + }{ + { + name: "missing arguments", + args: []string{"skill", "add"}, + wantErr: true, + errMsg: "accepts 2 arg(s)", + }, + { + name: "missing target", + args: []string{"skill", "add", "skill-123"}, + wantErr: true, + errMsg: "accepts 2 arg(s)", + }, + { + name: "too many arguments", + args: []string{"skill", "add", "skill-123", "qoder", "extra"}, + wantErr: true, + errMsg: "accepts 2 arg(s)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := NewRootCommand() + cmd.SetArgs(tt.args) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + if (err != nil) != tt.wantErr { + t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("error = %v, should contain %v", err, tt.errMsg) + } + }) + } +} + +func TestSkillAddInvalidTarget(t *testing.T) { + // Setup: Create config directory with valid token + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, "config") + t.Setenv("DWS_CONFIG_DIR", configDir) + + // Save a valid token + err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{ + AccessToken: "test-token", + RefreshToken: "refresh-token", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + }) + if err != nil { + t.Fatalf("failed to save token data: %v", err) + } + + cmd := NewRootCommand() + cmd.SetArgs([]string{"skill", "add", "skill-123", "invalid-target"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err = cmd.Execute() + if err == nil { + t.Error("Execute() should have failed for invalid target") + } + if !strings.Contains(err.Error(), "invalid target") { + t.Errorf("error should mention invalid target, got: %v", err) + } +} + +func TestSkillAddRequiresAuth(t *testing.T) { + // Setup: Create config directory without token + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, "config") + t.Setenv("DWS_CONFIG_DIR", configDir) + + // Ensure the config directory exists but has no token + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + + cmd := NewRootCommand() + cmd.SetArgs([]string{"skill", "add", "skill-123", "qoder"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + if err == nil { + t.Error("Execute() should have failed without auth") + } + if !strings.Contains(err.Error(), "not logged in") && !strings.Contains(err.Error(), "token") { + t.Errorf("error should mention authentication, got: %v", err) + } +} + +func TestFetchSkillDownloadInfoUnauthorized(t *testing.T) { + // Create mock server that returns 401 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + // We can't easily test the actual fetchSkillDownloadInfo function + // because it uses a hardcoded URL. This test verifies HTTP 401 handling pattern. + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(server.URL) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", resp.StatusCode) + } +} + +func TestSupportedTargets(t *testing.T) { + targets := supportedTargets() + + // Should contain all predefined targets + expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode", "."} + for _, expected := range expectedTargets { + if !strings.Contains(targets, expected) { + t.Errorf("supportedTargets() should contain %s, got: %s", expected, targets) + } + } +} + +func TestAgentSkillPathsCrossPlatform(t *testing.T) { + // Verify that paths use platform-appropriate separators + for target, path := range agentSkillPaths { + if runtime.GOOS == "windows" { + if strings.Contains(path, "/") && !strings.Contains(path, "\\") { + // On Windows, filepath.Join should use backslashes + // But raw map values may use forward slashes + t.Logf("Note: %s path '%s' uses forward slashes (will be converted by filepath.Join)", target, path) + } + } + + // Test that resolveSkillTargetPath produces valid paths + resolved, err := resolveSkillTargetPath(target) + if err != nil { + t.Errorf("resolveSkillTargetPath(%s) failed: %v", target, err) + continue + } + + // Path should be absolute + if !filepath.IsAbs(resolved) { + t.Errorf("resolveSkillTargetPath(%s) returned non-absolute path: %s", target, resolved) + } + } +} + +func TestCleanupTempFile(t *testing.T) { + // Create a temporary file + tempFile, err := os.CreateTemp("", "test-cleanup-*.txt") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + tempPath := tempFile.Name() + tempFile.Close() + + // Verify file exists + if _, err := os.Stat(tempPath); os.IsNotExist(err) { + t.Fatalf("temp file should exist before cleanup") + } + + // Clean up + cleanupTempFile(tempPath) + + // Verify file is deleted + if _, err := os.Stat(tempPath); !os.IsNotExist(err) { + t.Errorf("temp file should be deleted after cleanup") + } + + // Cleanup should not panic on empty path + cleanupTempFile("") + + // Cleanup should not panic on non-existent file + cleanupTempFile("/nonexistent/path/file.txt") +} + +func TestDownloadSkillResponseJSON(t *testing.T) { + // Test JSON marshaling/unmarshaling round-trip + original := downloadSkillResponse{ + Success: true, + Result: &downloadSkillResult{ + DownloadURL: "https://example.com/skill.zip", + FileName: "skill.zip", + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var parsed downloadSkillResponse + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if parsed.Success != original.Success { + t.Errorf("Success mismatch: got %v, want %v", parsed.Success, original.Success) + } + if parsed.Result.DownloadURL != original.Result.DownloadURL { + t.Errorf("DownloadURL mismatch: got %v, want %v", parsed.Result.DownloadURL, original.Result.DownloadURL) + } + if parsed.Result.FileName != original.Result.FileName { + t.Errorf("FileName mismatch: got %v, want %v", parsed.Result.FileName, original.Result.FileName) + } +} + +func TestSkillCommandHelp(t *testing.T) { + cmd := NewRootCommand() + cmd.SetArgs([]string{"skill", "--help"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + output := out.String() + // Check for the Long description which is shown in help + if !strings.Contains(output, "技能") { + t.Errorf("help should mention '技能', got: %s", output) + } + if !strings.Contains(output, "add") { + t.Errorf("help should mention 'add' subcommand, got: %s", output) + } +} + +func TestSkillAddCommandHelp(t *testing.T) { + cmd := NewRootCommand() + cmd.SetArgs([]string{"skill", "add", "--help"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + output := out.String() + // Should mention supported targets + expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode"} + for _, target := range expectedTargets { + if !strings.Contains(output, target) { + t.Errorf("help should mention target '%s', got: %s", target, output) + } + } +} + +// mockRoundTripper is a helper for mocking HTTP responses in tests +type mockSkillRoundTripper struct { + roundTripFunc func(*http.Request) (*http.Response, error) +} + +func (m *mockSkillRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return m.roundTripFunc(req) +} + +func TestDownloadSkillFileSuccess(t *testing.T) { + // Create a mock server that returns a zip file + expectedContent := []byte("fake zip content") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/zip") + w.WriteHeader(http.StatusOK) + w.Write(expectedContent) + })) + defer server.Close() + + // Download the file + ctx := context.Background() + tempPath, err := downloadSkillFile(ctx, server.URL, "test.zip") + if err != nil { + t.Fatalf("downloadSkillFile() error = %v", err) + } + defer os.Remove(tempPath) + + // Verify the downloaded content + content, err := os.ReadFile(tempPath) + if err != nil { + t.Fatalf("failed to read downloaded file: %v", err) + } + + if !bytes.Equal(content, expectedContent) { + t.Errorf("downloaded content mismatch: got %v, want %v", content, expectedContent) + } +} + +func TestDownloadSkillFileServerError(t *testing.T) { + // Create a mock server that returns 500 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + ctx := context.Background() + _, err := downloadSkillFile(ctx, server.URL, "test.zip") + if err == nil { + t.Error("downloadSkillFile() should fail on server error") + } +} + +func TestExtractSkillZipEmptyZip(t *testing.T) { + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "empty.zip") + destDir := filepath.Join(tempDir, "extracted") + + // Create an empty zip file + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("failed to create zip file: %v", err) + } + zipWriter := zip.NewWriter(zipFile) + if err := zipWriter.Close(); err != nil { + t.Fatalf("failed to close zip writer: %v", err) + } + if err := zipFile.Close(); err != nil { + t.Fatalf("failed to close zip file: %v", err) + } + + // Extract should succeed even for empty zip + if err := extractSkillZip(zipPath, destDir); err != nil { + t.Errorf("extractSkillZip() should not fail for empty zip: %v", err) + } + + // Destination directory should be created + if _, err := os.Stat(destDir); os.IsNotExist(err) { + t.Errorf("destination directory should be created") + } +} + +func TestExtractSkillZipWithDirectories(t *testing.T) { + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "test.zip") + destDir := filepath.Join(tempDir, "extracted") + + // Create zip with directory entries + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatalf("failed to create zip file: %v", err) + } + + zipWriter := zip.NewWriter(zipFile) + + // Add a directory entry with proper permissions + header := &zip.FileHeader{ + Name: "mydir/", + Method: zip.Deflate, + } + header.SetMode(0755 | os.ModeDir) + _, err = zipWriter.CreateHeader(header) + if err != nil { + t.Fatalf("failed to create directory in zip: %v", err) + } + + // Add a file in the directory + fileHeader := &zip.FileHeader{ + Name: "mydir/file.txt", + Method: zip.Deflate, + } + fileHeader.SetMode(0644) + writer, err := zipWriter.CreateHeader(fileHeader) + if err != nil { + t.Fatalf("failed to create file in zip: %v", err) + } + if _, err := writer.Write([]byte("content")); err != nil { + t.Fatalf("failed to write content: %v", err) + } + + if err := zipWriter.Close(); err != nil { + t.Fatalf("failed to close zip writer: %v", err) + } + if err := zipFile.Close(); err != nil { + t.Fatalf("failed to close zip file: %v", err) + } + + // Extract + if err := extractSkillZip(zipPath, destDir); err != nil { + t.Fatalf("extractSkillZip() error = %v", err) + } + + // Verify directory was created + dirPath := filepath.Join(destDir, "mydir") + info, err := os.Stat(dirPath) + if err != nil { + t.Errorf("directory should exist: %v", err) + } else if !info.IsDir() { + t.Errorf("mydir should be a directory") + } + + // Verify file exists + filePath := filepath.Join(destDir, "mydir", "file.txt") + content, err := os.ReadFile(filePath) + if err != nil { + t.Errorf("file should exist: %v", err) + } else if string(content) != "content" { + t.Errorf("file content mismatch: got %s, want 'content'", string(content)) + } +}