Introduce the plugin system for DWS CLI, enabling official and third-party plugin management. This includes plugin manifest parsing/validation, loader with managed/user directory-based identity, MCP server conversion and injection into the dynamic routing registry, pipeline hook adapter for shell-based hooks, and CLI commands (list/install/info/enable/disable/ remove/validate).
125 lines
3.2 KiB
Go
125 lines
3.2 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 plugin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
|
)
|
|
|
|
const defaultHookTimeout = 30 * time.Second
|
|
|
|
// HookAdapter wraps a plugin hook entry as a pipeline.Handler.
|
|
type HookAdapter struct {
|
|
pluginName string
|
|
entry HookEntry
|
|
phase pipeline.Phase
|
|
timeout time.Duration
|
|
}
|
|
|
|
// NewHookAdapter creates a pipeline handler from a plugin hook entry.
|
|
func NewHookAdapter(pluginName string, entry HookEntry) *HookAdapter {
|
|
phase := parsePhase(entry.Phase)
|
|
timeout := defaultHookTimeout
|
|
if entry.Timeout > 0 {
|
|
timeout = time.Duration(entry.Timeout) * time.Second
|
|
}
|
|
return &HookAdapter{
|
|
pluginName: pluginName,
|
|
entry: entry,
|
|
phase: phase,
|
|
timeout: timeout,
|
|
}
|
|
}
|
|
|
|
func (h *HookAdapter) Name() string {
|
|
return fmt.Sprintf("plugin-hook:%s/%s", h.pluginName, h.entry.Phase)
|
|
}
|
|
|
|
func (h *HookAdapter) Phase() pipeline.Phase {
|
|
return h.phase
|
|
}
|
|
|
|
func (h *HookAdapter) Handle(ctx *pipeline.Context) error {
|
|
// Check matcher: if set, only run for matching commands.
|
|
if h.entry.Matcher != "" {
|
|
matched, err := filepath.Match(h.entry.Matcher, ctx.Command)
|
|
if err != nil || !matched {
|
|
return nil // skip silently
|
|
}
|
|
}
|
|
|
|
// Serialize context to JSON for the hook's stdin.
|
|
input, err := json.Marshal(map[string]any{
|
|
"command": ctx.Command,
|
|
"params": ctx.Params,
|
|
"args": ctx.Args,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("plugin hook: failed to serialize context",
|
|
"plugin", h.pluginName, "error", err)
|
|
return nil
|
|
}
|
|
|
|
timeoutCtx, cancel := context.WithTimeout(context.Background(), h.timeout)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(timeoutCtx, "sh", "-c", h.entry.Command)
|
|
cmd.Stdin = strings.NewReader(string(input))
|
|
output, err := cmd.CombinedOutput()
|
|
|
|
if err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
code := exitErr.ExitCode()
|
|
if code == 2 {
|
|
// Exit 2 = abort pipeline.
|
|
return fmt.Errorf("plugin hook %s/%s aborted: %s",
|
|
h.pluginName, h.entry.Phase, strings.TrimSpace(string(output)))
|
|
}
|
|
}
|
|
slog.Warn("plugin hook failed",
|
|
"plugin", h.pluginName,
|
|
"phase", h.entry.Phase,
|
|
"error", err,
|
|
"output", string(output),
|
|
)
|
|
return nil // non-fatal: log warning and continue
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parsePhase(s string) pipeline.Phase {
|
|
switch strings.TrimSpace(strings.ToLower(s)) {
|
|
case "pre-parse":
|
|
return pipeline.PreParse
|
|
case "post-parse":
|
|
return pipeline.PostParse
|
|
case "pre-request":
|
|
return pipeline.PreRequest
|
|
case "post-response":
|
|
return pipeline.PostResponse
|
|
default:
|
|
return pipeline.PreRequest
|
|
}
|
|
}
|