diff --git a/internal/app/root.go b/internal/app/root.go index 6f15f852..cb210eef 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -624,6 +624,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) { "auth": true, "cache": true, "completion": true, + "skill": true, "version": true, "help": true, "recovery": true, diff --git a/internal/app/root_execute_test.go b/internal/app/root_execute_test.go index 178f31f4..e699e77c 100644 --- a/internal/app/root_execute_test.go +++ b/internal/app/root_execute_test.go @@ -255,6 +255,11 @@ func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) { if !strings.Contains(out.String(), "Discovered MCP Services:") { t.Fatalf("root help output missing MCP summary:\n%s", out.String()) } + for _, want := range []string{"Utility Commands:", "skill", "auth", "version"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("root help output missing %q:\n%s", want, out.String()) + } + } } func TestRootShortHelpDoesNotRequirePINOrLogin(t *testing.T) { diff --git a/internal/app/root_help.go b/internal/app/root_help.go index f005d05a..e55ed7f5 100644 --- a/internal/app/root_help.go +++ b/internal/app/root_help.go @@ -26,6 +26,7 @@ func configureRootHelp(root *cobra.Command) { func renderRootHelp(root *cobra.Command) { services := visibleMCPRootCommands(root) + utilities := visibleUtilityRootCommands(root) w := root.OutOrStdout() if len(services) == 0 { @@ -45,8 +46,21 @@ func renderRootHelp(root *cobra.Command) { _, _ = fmt.Fprintln(w, "Usage:") _, _ = fmt.Fprintln(w, " dws [command] [flags]") + if len(utilities) > 0 { + _, _ = fmt.Fprintln(w, " dws [flags]") + } _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, `Use "dws --help" for more information about a discovered MCP service.`) + if len(utilities) > 0 { + _, _ = fmt.Fprintln(w, "Utility Commands:") + _, _ = fmt.Fprintln(w) + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, utility := range utilities { + _, _ = fmt.Fprintf(tw, " %s\t%s\n", utility.Name(), strings.TrimSpace(utility.Short)) + } + _ = tw.Flush() + _, _ = fmt.Fprintln(w) + } + _, _ = fmt.Fprintln(w, `Use "dws --help" for more information about a discovered MCP service or "dws --help" for utility commands.`) } func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command { @@ -80,3 +94,29 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command { } return commands } + +func visibleUtilityRootCommands(root *cobra.Command) []*cobra.Command { + if root == nil { + return nil + } + + productCommands := DirectRuntimeProductIDs() + if fn := edition.Get().VisibleProducts; fn != nil { + productCommands = make(map[string]bool, len(fn())) + for _, product := range fn() { + productCommands[product] = true + } + } + + commands := make([]*cobra.Command, 0) + for _, cmd := range root.Commands() { + if cmd == nil || cmd.Hidden { + continue + } + if productCommands[cmd.Name()] { + continue + } + commands = append(commands, cmd) + } + return commands +} diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index 4a20cbb5..dbb35918 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -19,7 +19,9 @@ import ( "encoding/json" "fmt" "io" + "mime" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -31,6 +33,8 @@ import ( ) const ( + // legacySkillAPIHost is the legacy skill market host used by the old cli. + legacySkillAPIHost = "https://mcp.dingtalk.com" // skillDownloadEndpoint is the API endpoint for downloading skills. skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download" // skillDownloadTimeout is the timeout for skill download operations. @@ -51,6 +55,22 @@ type downloadSkillResult struct { 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 find`. +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. var agentSkillPaths = map[string]string{ @@ -75,7 +95,7 @@ func buildSkillCommand() *cobra.Command { cmd := &cobra.Command{ Use: "skill", Short: "技能管理", - Long: "管理钉钉技能市场的技能。支持下载和安装技能到指定 Agent 目录。", + Long: "管理钉钉技能市场的技能。支持搜索、下载与安装到指定 Agent 目录。", Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, @@ -84,10 +104,56 @@ func buildSkillCommand() *cobra.Command { }, } - cmd.AddCommand(newSkillAddCommand()) + cmd.AddCommand( + newSkillAddCommand(), + newSkillGetCommand(), + newSkillFindCommand(), + newSkillSearchHintCommand(), + ) 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 newSkillFindCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "find", + Short: "从钉钉技能市场搜索技能", + Long: "从钉钉技能市场搜索技能,根据关键词返回匹配的技能列表。", + Example: " dws skill find --context 关键词", + DisableAutoGenTag: true, + RunE: runSkillFind, + } + cmd.Flags().String("context", "", "搜索关键词(必填)") + _ = cmd.MarkFlagRequired("context") + return cmd +} + +func newSkillSearchHintCommand() *cobra.Command { + return &cobra.Command{ + Use: "search", + Short: "兼容旧用法,提示使用 skill find", + Hidden: true, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "use: dws skill find --context <关键词>") + return nil + }, + } +} + func newSkillAddCommand() *cobra.Command { cmd := &cobra.Command{ Use: "add ", @@ -118,6 +184,79 @@ func newSkillAddCommand() *cobra.Command { return cmd } +func runSkillGet(cmd *cobra.Command, args []string) error { + skillID, _ := cmd.Flags().GetString("skill-id") + accessToken, err := loadSkillAccessToken() + 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 := downloadSkillToTmpDir(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("context") + accessToken, err := loadSkillAccessToken() + if err != nil { + return err + } + + apiURL := fmt.Sprintf("%s/cli/find-skills?keyword=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(keyword))) + req, err := http.NewRequestWithContext(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 := client.Do(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]) @@ -132,13 +271,9 @@ func runSkillAdd(cmd *cobra.Command, args []string) error { 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")) + accessToken, err := loadSkillAccessToken() + if err != nil { + return err } ctx, cancel := context.WithTimeout(cmd.Context(), skillDownloadTimeout) @@ -148,7 +283,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error { // Step 1: Get download URL from API fmt.Fprintf(w, "正在获取技能信息...\n") - downloadResp, err := fetchSkillDownloadInfo(ctx, tokenData.AccessToken, skillID) + downloadResp, err := fetchSkillDownloadInfo(ctx, accessToken, skillID) if err != nil { return err } @@ -189,6 +324,24 @@ func runSkillAdd(cmd *cobra.Command, args []string) error { return nil } +func loadSkillAccessToken() (string, error) { + 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")) + } + return tokenData.AccessToken, nil +} + +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) @@ -259,6 +412,72 @@ func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (* return &result, nil } +func downloadSkillToTmpDir(ctx context.Context, apiURL, accessToken string) (string, error) { + req, err := http.NewRequestWithContext(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 := client.Do(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 := os.MkdirTemp("", "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 := os.Create(destPath) + if err != nil { + os.RemoveAll(tmpDir) + return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err)) + } + defer file.Close() + + if _, err := io.Copy(file, resp.Body); err != nil { + os.RemoveAll(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 apperrors.NewAuth("authentication failed. Please run 'dws auth login' to refresh your token", + apperrors.WithHint("请执行 'dws auth login' 重新登录"), + apperrors.WithActions("dws auth login")) + 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) diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index c19524f2..b2a064f8 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -563,8 +563,10 @@ func TestSkillCommandHelp(t *testing.T) { 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) + for _, subcmd := range []string{"add", "find", "get"} { + if !strings.Contains(output, subcmd) { + t.Errorf("help should mention %q subcommand, got: %s", subcmd, output) + } } } @@ -590,6 +592,56 @@ func TestSkillAddCommandHelp(t *testing.T) { } } +func TestSkillGetCommandValidation(t *testing.T) { + cmd := NewRootCommand() + cmd.SetArgs([]string{"skill", "get"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want missing required flag error") + } + if !strings.Contains(err.Error(), "required flag") { + t.Fatalf("error = %v, want required flag message", err) + } +} + +func TestSkillFindCommandValidation(t *testing.T) { + cmd := NewRootCommand() + cmd.SetArgs([]string{"skill", "find"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want missing required flag error") + } + if !strings.Contains(err.Error(), "required flag") { + t.Fatalf("error = %v, want required flag message", err) + } +} + +func TestSkillSearchHintCommand(t *testing.T) { + cmd := NewRootCommand() + cmd.SetArgs([]string{"skill", "search"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !strings.Contains(out.String(), "dws skill find --context") { + t.Fatalf("output = %q, want legacy hint", out.String()) + } +} + func TestDownloadSkillFileSuccess(t *testing.T) { // Create a mock server that returns a zip file expectedContent := []byte("fake zip content")