Files

237 lines
7.5 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 userdef loads user-defined shortcuts from ~/.dws/shortcuts/*.yaml and
// compiles them into the same shortcut.Shortcut model as the built-ins, so a
// distilled high-frequency operation behaves exactly like a native `+command`.
// This is the runtime half of the "high-frequency auto-distillation" story
// (see docs/shortcut-p2-design.md); the `dws shortcut add` command writes these
// YAML files.
package userdef
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"gopkg.in/yaml.v3"
)
var commandSegmentRE = regexp.MustCompile(`^[\p{L}\p{N}_-][\p{L}\p{N}._-]*$`)
// Spec is the on-disk YAML form of a user-defined shortcut. It mirrors
// shortcut.Shortcut but expresses the Execute step declaratively via Exec.Bind.
type Spec struct {
Version int `yaml:"version"`
Service string `yaml:"service"`
Command string `yaml:"command"`
Product string `yaml:"product"`
Description string `yaml:"description"`
Intent string `yaml:"intent"`
Risk string `yaml:"risk"`
Source string `yaml:"source"` // auto | manual
Flags []FlagSpec `yaml:"flags"`
Exec ExecSpec `yaml:"execute"`
}
// FlagSpec is the YAML form of shortcut.Flag.
type FlagSpec struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Default string `yaml:"default"`
Desc string `yaml:"desc"`
Required bool `yaml:"required"`
Enum []string `yaml:"enum"`
}
// ExecSpec declares the single MCP call. Bind maps each tool parameter key to
// either a constant string, or a "${flag}" reference resolved from a flag value.
type ExecSpec struct {
Tool string `yaml:"tool"`
Bind map[string]string `yaml:"bind"`
}
// Dir returns the directory holding user shortcut YAML files (~/.dws/shortcuts).
func Dir() string { return filepath.Join(config.DefaultConfigDir(), "shortcuts") }
// Load reads every ~/.dws/shortcuts/*.yaml, compiles valid specs, and registers
// them (skipping any that collide with an already-registered service+command,
// so built-ins always win). Returns the number registered and any per-file
// errors. It never fails hard — a bad file is reported and skipped.
func Load() (registered int, errs []error) {
files, _ := filepath.Glob(filepath.Join(Dir(), "*.yaml"))
existing := registeredKeys()
for _, f := range files {
s, err := parseFile(f)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", filepath.Base(f), err))
continue
}
key := s.Service + " " + s.Command
if existing[key] {
errs = append(errs, fmt.Errorf("%s: 与已有 shortcut %q 冲突,已跳过", filepath.Base(f), key))
continue
}
shortcut.Register(Compile(s))
existing[key] = true
registered++
}
return registered, errs
}
func registeredKeys() map[string]bool {
set := map[string]bool{}
for _, s := range shortcut.All() {
set[s.Service+" "+s.Command] = true
}
return set
}
func parseFile(path string) (Spec, error) {
data, err := os.ReadFile(path)
if err != nil {
return Spec{}, err
}
var s Spec
if err := yaml.Unmarshal(data, &s); err != nil {
return Spec{}, err
}
return s, Validate(s)
}
// Validate checks the minimum required fields of a spec.
func Validate(s Spec) error {
if s.Service == "" || s.Command == "" {
return fmt.Errorf("service 和 command 为必填")
}
if !strings.HasPrefix(s.Command, "+") {
return fmt.Errorf("command 必须以 + 开头,当前 %q", s.Command)
}
if !commandSegmentRE.MatchString(s.Service) ||
!commandSegmentRE.MatchString(strings.TrimPrefix(s.Command, "+")) {
return fmt.Errorf("service 和 command 只能包含字母、数字、点、下划线和连字符,且不能以点开头")
}
if s.Exec.Tool == "" {
return fmt.Errorf("execute.tool 为必填")
}
return nil
}
// FilePath returns the safe on-disk path for a user-defined shortcut. Validate
// already constrains both components, while the Rel check is a final defense
// against future validation changes reintroducing directory traversal.
func FilePath(service, command string) (string, error) {
probe := Spec{Service: service, Command: command, Exec: ExecSpec{Tool: "path-check"}}
if err := Validate(probe); err != nil {
return "", err
}
dir := Dir()
name := service + "." + strings.TrimPrefix(command, "+") + ".yaml"
path := filepath.Join(dir, name)
rel, err := filepath.Rel(dir, path)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("shortcut 文件路径超出配置目录")
}
return path, nil
}
// Compile turns a validated Spec into a runnable shortcut.Shortcut. Its Execute
// builds the MCP params from Exec.Bind: "${flag}" values are resolved from the
// flag (respecting its type), everything else is a literal constant. Optional
// flag references that the user did not set are omitted.
func Compile(s Spec) shortcut.Shortcut {
flags := make([]shortcut.Flag, 0, len(s.Flags))
flagType := map[string]shortcut.FlagType{}
flagRequired := map[string]bool{}
flagHasDefault := map[string]bool{}
for _, f := range s.Flags {
t := shortcut.FlagType(f.Type)
if t == "" {
t = shortcut.FlagString
}
flagType[f.Name] = t
flagRequired[f.Name] = f.Required
flagHasDefault[f.Name] = f.Default != ""
flags = append(flags, shortcut.Flag{
Name: f.Name, Type: t, Default: f.Default,
Desc: f.Desc, Required: f.Required, Enum: f.Enum,
})
}
risk := shortcut.Risk(s.Risk)
if risk == "" {
risk = shortcut.RiskRead
}
desc := s.Description
if desc == "" {
desc = s.Command + "(自定义)"
}
intent := s.Intent
if intent == "" {
intent = "用户自定义 shortcut(沉淀自高频操作):" + desc
}
bind := s.Exec.Bind
tool := s.Exec.Tool
return shortcut.Shortcut{
Service: s.Service,
Command: s.Command,
Product: s.Product,
Description: desc,
Intent: intent,
Risk: risk,
Flags: flags,
UserDefined: true,
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
for key, tmpl := range bind {
if name, ok := flagRef(tmpl); ok {
if !rt.Changed(name) && !flagRequired[name] && !flagHasDefault[name] {
continue // optional flag not provided → omit param
}
params[key] = readFlag(rt, name, flagType[name])
} else {
params[key] = tmpl // constant
}
}
return rt.CallMCP(tool, params)
},
}
}
// flagRef reports whether v is a "${name}" reference and returns name.
func flagRef(v string) (string, bool) {
if strings.HasPrefix(v, "${") && strings.HasSuffix(v, "}") {
return strings.TrimSpace(v[2 : len(v)-1]), true
}
return "", false
}
func readFlag(rt *shortcut.RuntimeContext, name string, t shortcut.FlagType) any {
switch t {
case shortcut.FlagBool:
return rt.Bool(name)
case shortcut.FlagInt:
return rt.Int(name)
case shortcut.FlagStringSlice:
return rt.StrSlice(name)
default:
return rt.Str(name)
}
}