- dws plugin create: generates plugin template with plugin.json, SKILL.md, hooks.json - dws plugin dev: registers source directory as dev plugin without copying - SyncSkills: copies plugin skills to agent directories on startup
413 lines
12 KiB
Go
413 lines
12 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func newPluginCommand() *cobra.Command {
|
|
pluginCmd := newPlaceholderParent("plugin", "Manage plugins")
|
|
|
|
pluginCmd.AddCommand(
|
|
newPluginListCommand(),
|
|
newPluginInstallCommand(),
|
|
newPluginInfoCommand(),
|
|
newPluginEnableCommand(),
|
|
newPluginDisableCommand(),
|
|
newPluginRemoveCommand(),
|
|
newPluginValidateCommand(),
|
|
newPluginCreateCommand(),
|
|
newPluginDevCommand(),
|
|
)
|
|
|
|
return pluginCmd
|
|
}
|
|
|
|
func newPluginListCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List installed plugins",
|
|
DisableAutoGenTag: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
loader := plugin.NewLoader(RawVersion())
|
|
plugins := loader.ListInstalled()
|
|
|
|
wantJSON, _ := cmd.Flags().GetBool("json")
|
|
if wantJSON {
|
|
return output.WriteJSON(cmd.OutOrStdout(), plugins)
|
|
}
|
|
|
|
if len(plugins) == 0 {
|
|
fmt.Fprintln(cmd.OutOrStdout(), "No plugins installed.")
|
|
return nil
|
|
}
|
|
|
|
w := cmd.OutOrStdout()
|
|
fmt.Fprintf(w, "%-35s %-12s %-10s %-10s %s\n",
|
|
"NAME", "VERSION", "TYPE", "STATUS", "DESCRIPTION")
|
|
fmt.Fprintln(w, strings.Repeat("-", 85))
|
|
for _, p := range plugins {
|
|
fmt.Fprintf(w, "%-35s %-12s %-10s %-10s %s\n",
|
|
p.Name, p.Version, p.Type, statusStr(p.Enabled), p.Description)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().Bool("json", false, "Output in JSON format")
|
|
return cmd
|
|
}
|
|
|
|
func newPluginInstallCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "install",
|
|
Short: "Install a plugin",
|
|
Example: ` dws plugin install --dir ./conference
|
|
dws plugin install --git https://github.com/DingTalk-Real-AI/conference.git`,
|
|
DisableAutoGenTag: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
dirPath, _ := cmd.Flags().GetString("dir")
|
|
gitURL, _ := cmd.Flags().GetString("git")
|
|
|
|
if dirPath == "" && gitURL == "" {
|
|
return apperrors.NewValidation("specify install source: --dir <path> or --git <url>")
|
|
}
|
|
|
|
loader := plugin.NewLoader(RawVersion())
|
|
|
|
if gitURL != "" {
|
|
p, err := loader.InstallFromGit(gitURL)
|
|
if err != nil {
|
|
return apperrors.NewInternal(fmt.Sprintf("install failed: %v", err))
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "Installed %s (%s)\n", p.Manifest.Name, p.Manifest.Version)
|
|
return nil
|
|
}
|
|
|
|
p, err := loader.InstallFromDir(dirPath)
|
|
if err != nil {
|
|
return apperrors.NewInternal(fmt.Sprintf("install failed: %v", err))
|
|
}
|
|
|
|
fmt.Fprintf(cmd.OutOrStdout(), "Installed %s (%s)\n", p.Manifest.Name, p.Manifest.Version)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().String("dir", "", "Install from a local directory")
|
|
cmd.Flags().String("git", "", "Install from a Git repository")
|
|
return cmd
|
|
}
|
|
|
|
func newPluginInfoCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "info <name>",
|
|
Short: "Show plugin details",
|
|
Args: cobra.ExactArgs(1),
|
|
DisableAutoGenTag: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
name := args[0]
|
|
loader := plugin.NewLoader(RawVersion())
|
|
plugins := loader.ListInstalled()
|
|
|
|
for _, p := range plugins {
|
|
if p.Name == name {
|
|
w := cmd.OutOrStdout()
|
|
fmt.Fprintf(w, "Name: %s\n", p.Name)
|
|
fmt.Fprintf(w, "Version: %s\n", p.Version)
|
|
fmt.Fprintf(w, "Type: %s\n", p.Type)
|
|
fmt.Fprintf(w, "Status: %s\n", statusStr(p.Enabled))
|
|
fmt.Fprintf(w, "Path: %s\n", p.Path)
|
|
if p.Description != "" {
|
|
fmt.Fprintf(w, "Description: %s\n", p.Description)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
return apperrors.NewValidation(fmt.Sprintf("plugin %q not found", name))
|
|
},
|
|
}
|
|
}
|
|
|
|
func newPluginEnableCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "enable <name>",
|
|
Short: "Enable a plugin",
|
|
Args: cobra.ExactArgs(1),
|
|
DisableAutoGenTag: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
loader := plugin.NewLoader(RawVersion())
|
|
if err := loader.SetEnabled(args[0], true); err != nil {
|
|
return apperrors.NewValidation(err.Error())
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "Plugin %s enabled.\n", args[0])
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func newPluginDisableCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "disable <name>",
|
|
Short: "Disable a plugin (managed plugins can be disabled but not removed)",
|
|
Args: cobra.ExactArgs(1),
|
|
DisableAutoGenTag: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
loader := plugin.NewLoader(RawVersion())
|
|
if err := loader.SetEnabled(args[0], false); err != nil {
|
|
return apperrors.NewValidation(err.Error())
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "Plugin %s disabled.\n", args[0])
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func newPluginRemoveCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "remove <name>",
|
|
Short: "Remove a user plugin (managed plugins cannot be removed)",
|
|
Args: cobra.ExactArgs(1),
|
|
DisableAutoGenTag: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
keepData, _ := cmd.Flags().GetBool("keep-data")
|
|
loader := plugin.NewLoader(RawVersion())
|
|
if err := loader.RemovePlugin(args[0], keepData); err != nil {
|
|
return apperrors.NewValidation(err.Error())
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "Plugin %s removed.\n", args[0])
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().Bool("keep-data", false, "Keep plugin data directory")
|
|
return cmd
|
|
}
|
|
|
|
func newPluginValidateCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "validate <dir>",
|
|
Short: "Validate a plugin.json",
|
|
Args: cobra.ExactArgs(1),
|
|
DisableAutoGenTag: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
dir := args[0]
|
|
m, err := plugin.ParseManifest(dir + "/plugin.json")
|
|
if err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("parse failed: %v", err))
|
|
}
|
|
if err := m.Validate(RawVersion()); err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("validation failed: %v", err))
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "Valid: %s (%s)\n", m.Name, m.Version)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func newPluginCreateCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "create <name>",
|
|
Short: "Scaffold a new plugin directory",
|
|
Example: ` dws plugin create my-tool
|
|
dws plugin create my-tool --type managed --description "My awesome tool"`,
|
|
Args: cobra.ExactArgs(1),
|
|
DisableAutoGenTag: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
name := args[0]
|
|
desc, _ := cmd.Flags().GetString("description")
|
|
pluginType, _ := cmd.Flags().GetString("type")
|
|
|
|
if pluginType == "" {
|
|
pluginType = "user"
|
|
}
|
|
if pluginType != "managed" && pluginType != "user" {
|
|
return apperrors.NewValidation("type must be 'managed' or 'user'")
|
|
}
|
|
|
|
// Validate name format
|
|
m := &plugin.Manifest{Name: name, Version: "0.1.0", Type: pluginType}
|
|
if err := m.Validate(""); err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("invalid plugin name: %v", err))
|
|
}
|
|
|
|
dir := filepath.Join(".", name)
|
|
if _, err := os.Stat(dir); err == nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("directory %q already exists", dir))
|
|
}
|
|
|
|
// Create directory structure
|
|
dirs := []string{
|
|
dir,
|
|
filepath.Join(dir, "skills", name),
|
|
filepath.Join(dir, "hooks"),
|
|
}
|
|
for _, d := range dirs {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
|
|
}
|
|
}
|
|
|
|
// Write plugin.json
|
|
pluginJSON := fmt.Sprintf(`{
|
|
"name": %q,
|
|
"version": "0.1.0",
|
|
"description": %q,
|
|
"type": %q,
|
|
"minCLIVersion": %q,
|
|
"mcpServers": {
|
|
%q: {
|
|
"type": "stdio",
|
|
"command": "${DWS_PLUGIN_ROOT}/bin/%s",
|
|
"args": []
|
|
}
|
|
},
|
|
"skills": "./skills/",
|
|
"hooks": "./hooks/hooks.json"
|
|
}
|
|
`, name, desc, pluginType, RawVersion(), name, name)
|
|
|
|
if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil {
|
|
return apperrors.NewInternal(fmt.Sprintf("failed to write plugin.json: %v", err))
|
|
}
|
|
|
|
// Write SKILL.md template
|
|
skillMD := fmt.Sprintf(`---
|
|
name: %s
|
|
description: %s
|
|
cli_version: ">=%s"
|
|
---
|
|
|
|
# %s
|
|
|
|
## Intent Recognition
|
|
|
|
Use this skill when the user mentions:
|
|
- TODO: add your intent keywords here
|
|
|
|
## Command Decision Tree
|
|
|
|
| User Intent | Command | Required Parameters |
|
|
|-------------|---------|---------------------|
|
|
| TODO | `+"`dws %s <sub-command>`"+` | `+"`--param`"+` |
|
|
|
|
## Parameter Rules
|
|
|
|
### TODO: parameter type
|
|
- Format description
|
|
- Conversion rules
|
|
`, name, desc, RawVersion(), name, name)
|
|
|
|
if err := os.WriteFile(filepath.Join(dir, "skills", name, "SKILL.md"), []byte(skillMD), 0o644); err != nil {
|
|
return apperrors.NewInternal(fmt.Sprintf("failed to write SKILL.md: %v", err))
|
|
}
|
|
|
|
// Write hooks.json template
|
|
hooksJSON := `{
|
|
"hooks": []
|
|
}
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, "hooks", "hooks.json"), []byte(hooksJSON), 0o644); err != nil {
|
|
return apperrors.NewInternal(fmt.Sprintf("failed to write hooks.json: %v", err))
|
|
}
|
|
|
|
w := cmd.OutOrStdout()
|
|
fmt.Fprintf(w, "Created plugin scaffold at ./%s/\n", name)
|
|
fmt.Fprintf(w, " %s/\n", name)
|
|
fmt.Fprintf(w, " ├── plugin.json\n")
|
|
fmt.Fprintf(w, " ├── skills/%s/SKILL.md\n", name)
|
|
fmt.Fprintf(w, " └── hooks/hooks.json\n")
|
|
fmt.Fprintln(w)
|
|
fmt.Fprintf(w, "Next steps:\n")
|
|
fmt.Fprintf(w, " 1. Edit plugin.json to configure your MCP servers\n")
|
|
fmt.Fprintf(w, " 2. Edit skills/%s/SKILL.md to describe your commands\n", name)
|
|
fmt.Fprintf(w, " 3. Run: dws plugin validate ./%s\n", name)
|
|
fmt.Fprintf(w, " 4. Run: dws plugin dev ./%s\n", name)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().String("description", "", "Plugin description")
|
|
cmd.Flags().String("type", "user", "Plugin type: managed or user")
|
|
return cmd
|
|
}
|
|
|
|
func newPluginDevCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "dev <dir>",
|
|
Short: "Register a local directory as a dev plugin",
|
|
Long: `Registers a plugin from a local source directory for development.
|
|
The plugin is loaded directly from the source directory on next CLI invocation,
|
|
without copying files to ~/.dws/plugins/. Use 'dws plugin dev --off <name>'
|
|
to unregister.`,
|
|
Example: ` dws plugin dev ./my-tool
|
|
dws plugin dev --off my-tool`,
|
|
Args: cobra.ExactArgs(1),
|
|
DisableAutoGenTag: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
off, _ := cmd.Flags().GetBool("off")
|
|
loader := plugin.NewLoader(RawVersion())
|
|
|
|
if off {
|
|
// Unregister dev plugin
|
|
name := args[0]
|
|
if err := loader.UnregisterDevPlugin(name); err != nil {
|
|
return apperrors.NewValidation(err.Error())
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "Dev plugin %q unregistered.\n", name)
|
|
return nil
|
|
}
|
|
|
|
// Register dev plugin
|
|
dir := args[0]
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err))
|
|
}
|
|
|
|
// Validate the plugin first
|
|
m, err := plugin.ParseManifest(filepath.Join(absDir, "plugin.json"))
|
|
if err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("invalid plugin at %s: %v", dir, err))
|
|
}
|
|
if err := m.Validate(RawVersion()); err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("validation failed: %v", err))
|
|
}
|
|
|
|
if err := loader.RegisterDevPlugin(m.Name, absDir); err != nil {
|
|
return apperrors.NewInternal(fmt.Sprintf("failed to register: %v", err))
|
|
}
|
|
|
|
fmt.Fprintf(cmd.OutOrStdout(), "Dev plugin %q registered from %s\n", m.Name, absDir)
|
|
fmt.Fprintf(cmd.OutOrStdout(), "It will be loaded on next dws invocation.\n")
|
|
fmt.Fprintf(cmd.OutOrStdout(), "To unregister: dws plugin dev --off %s\n", m.Name)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().Bool("off", false, "Unregister a dev plugin")
|
|
return cmd
|
|
}
|
|
|
|
func statusStr(enabled bool) string {
|
|
if enabled {
|
|
return "enabled"
|
|
}
|
|
return "disabled"
|
|
}
|