Sync with DingTalk-Real-AI/main (1.0.35–1.0.40): - 1.0.35–1.0.39: chat @-mentions, sheet export, doc strip unsafe chars, PAT batch grants, devdoc RAG, agent_code detection, AI-tag opt-in - 1.0.40 (#486): China mirror via Gitee + npmmirror - (#488) embed skills/ into binary - (#464) strip duplicate H1 on JSONML doc-create # Conflicts: # .github/workflows/release.yml # CHANGELOG.md # README.md # README_zh.md # docs/command-index.md # internal/app/runner.go # internal/app/runner_test.go # internal/auth/channel.go # internal/helpers/devdoc.go # internal/helpers/devdoc_test.go # internal/pat/chmod.go # internal/pat/chmod_test.go # internal/pat/pat.go # internal/transport/client.go # scripts/install-skills.sh # scripts/install.ps1 # scripts/install.sh # skills/mono/references/products/devdoc.md # skills/mono/references/products/simple.md # skills/multi/dingtalk-devdoc/references/devdoc.md # skills/multi/dingtalk-skill/references/skill.md # test/cli_compat/devdoc_test.go # test/cli_compat/helpers_test.go # test/skill_tests.md # test/skill_tests_results.md # test/unit/pat_host_owned_signal_test.go
1707 lines
58 KiB
Go
1707 lines
58 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 (
|
||
"context"
|
||
"encoding/json"
|
||
stderrors "errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/url"
|
||
"os"
|
||
"os/signal"
|
||
"path"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||
"github.com/spf13/cobra"
|
||
"github.com/spf13/pflag"
|
||
)
|
||
|
||
type outputFileContextKey struct{}
|
||
|
||
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
|
||
|
||
// Execute runs the root command and returns the process exit code.
|
||
func Execute() (exitCode int) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
||
exitCode = 5
|
||
}
|
||
}()
|
||
|
||
timing := NewTimingCollector()
|
||
defer func() {
|
||
StopAllStdioClients() // Ensure child processes are terminated on exit
|
||
timing.PrintIfEnabled()
|
||
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
|
||
}()
|
||
|
||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||
defer cancel()
|
||
|
||
// Attach timing collector to context for use by child components
|
||
ctx = WithTimingCollector(ctx, timing)
|
||
|
||
initStart := time.Now()
|
||
recovery.ResetRuntimeState()
|
||
engine := newPipelineEngine()
|
||
root := NewRootCommandWithEngine(ctx, engine)
|
||
timing.Record("cmd_init", time.Since(initStart))
|
||
|
||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||
// This corrects model-generated errors like --userId → --user-id
|
||
// and --limit100 → --limit 100.
|
||
pipeline.RunPreParse(root, engine)
|
||
|
||
executed, err := root.ExecuteC()
|
||
if err != nil {
|
||
if executed == nil {
|
||
executed = root
|
||
}
|
||
if isUnknownCommandError(err) {
|
||
executed.SetOut(os.Stderr)
|
||
_ = executed.Help()
|
||
_, _ = fmt.Fprintln(os.Stderr)
|
||
}
|
||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||
if last := recovery.LatestCapture(); last != nil && last.EventID != "" {
|
||
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
|
||
}
|
||
return apperrors.ExitCode(err)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func isUnknownCommandError(err error) bool {
|
||
return err != nil && strings.Contains(err.Error(), "unknown command")
|
||
}
|
||
|
||
// flagErrorWithSuggestions provides helpful suggestions for common flag mistakes.
|
||
//
|
||
// 所有 flag 解析错误都会在 message 末尾追加 "See '<CommandPath> --help' for usage.",
|
||
// 与 docker / kubectl / gh / wukong CLI 的 UX 一致,方便用户/agent 复制完整命令查 help。
|
||
// 装在 root 的 FlagErrorFunc 通过 cobra 的 parent fallback 机制覆盖全命令树
|
||
// (cobra.Command.FlagErrorFunc 沿 c.parent 递归向上查找)。
|
||
func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||
errMsg := err.Error()
|
||
// 尾部 hint:换行 + See '...' for usage.
|
||
// JSON 输出时 \n 会被序列化为字面 \n,文本输出时换行;
|
||
// 无论哪种格式,子串 "--help' for usage." 都可被检索到。
|
||
tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath())
|
||
msgWithTail := errMsg + tail
|
||
|
||
// Common flag aliases and suggestions
|
||
suggestions := map[string]string{
|
||
"--json": "提示: 请使用 --format json 或 -f json 来输出 JSON 格式",
|
||
"--method": "提示: dws auth login 默认使用 OAuth loopback 流;SSH/无头环境请加 --device 走设备流",
|
||
"--device-flow": "提示: 设备流的标志名是 --device(不是 --device-flow),SSH/无头环境登录请用 dws auth login --device",
|
||
"--email": "提示: dws 不支持邮箱/密码登录,请使用 dws auth login 进行扫码登录",
|
||
"--code": "提示: dws 不支持验证码登录,请使用 dws auth login 进行扫码登录",
|
||
"--corp-id": "提示: corp-id 会在登录时自动获取,无需手动指定",
|
||
"--password": "提示: dws 不支持密码登录,请使用 dws auth login 进行扫码登录",
|
||
"--phone": "提示: dws 不支持手机号登录,请使用 dws auth login 进行扫码登录",
|
||
"--app-key": "提示: 请使用环境变量 DWS_CLIENT_ID 或 --client-id 设置 AppKey",
|
||
"--app-secret": "提示: 请使用环境变量 DWS_CLIENT_SECRET 或 --client-secret 设置 AppSecret",
|
||
}
|
||
|
||
for flag, suggestion := range suggestions {
|
||
if strings.Contains(errMsg, "unknown flag: "+flag) {
|
||
return apperrors.NewValidation(
|
||
msgWithTail,
|
||
apperrors.WithHint(suggestion),
|
||
apperrors.WithReason("unknown_flag"),
|
||
apperrors.WithCause(err),
|
||
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
|
||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||
)
|
||
}
|
||
}
|
||
|
||
if strings.Contains(errMsg, "unknown flag:") {
|
||
fix := cmdutil.SuggestFlagFix(cmd, err)
|
||
if fix.Suggestion != "" {
|
||
return apperrors.NewValidation(
|
||
msgWithTail,
|
||
apperrors.WithHint(fix.Suggestion),
|
||
apperrors.WithReason("unknown_flag"),
|
||
apperrors.WithCause(err),
|
||
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
|
||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||
)
|
||
}
|
||
}
|
||
|
||
// Fallback:未命中已知别名 / SuggestFlagFix 未给建议的 flag 解析错误
|
||
// (missing required / ambiguous / unknown shorthand 等),仍包尾部 hint,
|
||
// 行为对齐 wukong / docker / kubectl。
|
||
return fmt.Errorf("%s%s", errMsg, tail)
|
||
}
|
||
|
||
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
|
||
var raw apperrors.RawStderrError
|
||
if stderrors.As(err, &raw) {
|
||
_, writeErr := fmt.Fprintln(stderr, raw.RawStderr())
|
||
return writeErr
|
||
}
|
||
if wantsJSONErrors(root) {
|
||
return apperrors.PrintJSON(stderr, err)
|
||
}
|
||
return apperrors.PrintHumanAt(stderr, err, resolveVerbosity(root))
|
||
}
|
||
|
||
// resolveVerbosity derives the error verbosity level from the root command's flags.
|
||
func resolveVerbosity(cmd *cobra.Command) apperrors.Verbosity {
|
||
if cmd == nil {
|
||
return apperrors.VerbosityNormal
|
||
}
|
||
if debug, err := cmd.Flags().GetBool("debug"); err == nil && debug {
|
||
return apperrors.VerbosityDebug
|
||
}
|
||
if verbose, err := cmd.Flags().GetBool("verbose"); err == nil && verbose {
|
||
return apperrors.VerbosityVerbose
|
||
}
|
||
return apperrors.VerbosityNormal
|
||
}
|
||
|
||
func wantsJSONErrors(root *cobra.Command) bool {
|
||
if root == nil {
|
||
return false
|
||
}
|
||
if commandRequestsJSONErrors(root) {
|
||
return true
|
||
}
|
||
if rootCmd := root.Root(); rootCmd != nil && rootCmd != root {
|
||
return commandRequestsJSONErrors(rootCmd)
|
||
}
|
||
return false
|
||
}
|
||
|
||
func commandRequestsJSONErrors(cmd *cobra.Command) bool {
|
||
if cmd == nil {
|
||
return false
|
||
}
|
||
for _, flags := range []interface {
|
||
Lookup(string) *pflag.Flag
|
||
GetString(string) (string, error)
|
||
GetBool(string) (bool, error)
|
||
}{
|
||
cmd.Flags(),
|
||
cmd.InheritedFlags(),
|
||
cmd.PersistentFlags(),
|
||
} {
|
||
if flags == nil {
|
||
continue
|
||
}
|
||
if flag := flags.Lookup("format"); flag != nil {
|
||
if value, err := flags.GetString("format"); err == nil && strings.EqualFold(strings.TrimSpace(value), "json") {
|
||
return true
|
||
}
|
||
}
|
||
if flag := flags.Lookup("json"); flag != nil && flag.Changed {
|
||
if value, err := flags.GetBool("json"); err == nil {
|
||
if value {
|
||
return true
|
||
}
|
||
continue
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// NewRootCommand constructs the root CLI command. The provided context
|
||
// is propagated to background goroutines and the Cobra command tree so
|
||
// that SIGINT/SIGTERM can cancel in-flight work.
|
||
func NewRootCommand(ctx ...context.Context) *cobra.Command {
|
||
var rootCtx context.Context
|
||
if len(ctx) > 0 && ctx[0] != nil {
|
||
rootCtx = ctx[0]
|
||
}
|
||
return NewRootCommandWithEngine(rootCtx, nil)
|
||
}
|
||
|
||
// NewRootCommandWithEngine constructs the root CLI command with an
|
||
// optional pipeline engine for input correction. When engine is nil,
|
||
// no pipeline processing is applied.
|
||
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||
if rootCtx == nil {
|
||
rootCtx = context.Background()
|
||
}
|
||
flags := &GlobalFlags{}
|
||
loader := cli.EnvironmentLoader{
|
||
LookupEnv: os.LookupEnv,
|
||
CatalogBaseURLOverride: DiscoveryBaseURL(),
|
||
AuthTokenFunc: func(ctx context.Context) string {
|
||
return resolveRuntimeAuthToken(ctx, "")
|
||
},
|
||
LoggerFunc: FileLoggerInstance,
|
||
}
|
||
runner := newCommandRunnerWithFlags(loader, flags)
|
||
|
||
root := &cobra.Command{
|
||
Use: "dws",
|
||
Short: "DWS CLI",
|
||
Long: `提示: 如果遇到能力缺失、命令报错、新功能未注册、或无法完成任务, 请先用 'dws upgrade' 升级到最新版本后再试. 钉钉 OpenAPI 和 dws CLI 持续迭代, 新能力和 bugfix 会先在新版本上线.`,
|
||
Args: cobra.NoArgs,
|
||
SilenceErrors: true,
|
||
SilenceUsage: true,
|
||
DisableAutoGenTag: true,
|
||
Version: Version(),
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return cmd.Help()
|
||
},
|
||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||
// Apply OAuth credential overrides from CLI flags (highest priority).
|
||
if flags.ClientID != "" {
|
||
authpkg.SetClientID(flags.ClientID)
|
||
}
|
||
if flags.ClientSecret != "" {
|
||
authpkg.SetClientSecret(flags.ClientSecret)
|
||
}
|
||
|
||
// Configure global slog level based on --debug / --verbose flags.
|
||
configureLogLevel(flags)
|
||
|
||
if err := configureOutputSink(cmd); err != nil {
|
||
return err
|
||
}
|
||
if fn := edition.Get().AfterPersistentPreRun; fn != nil {
|
||
return fn(cmd, args)
|
||
}
|
||
return nil
|
||
},
|
||
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
|
||
StopAllStdioClients()
|
||
CloseFileLogger()
|
||
return closeOutputSink(cmd)
|
||
},
|
||
}
|
||
|
||
bindPersistentFlags(root, flags)
|
||
|
||
schemaCmd := newSchemaCommand(loader)
|
||
genSkillsCmd := newGenerateSkillsCommand()
|
||
genSkillsCmd.Hidden = true
|
||
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
|
||
mcpCmd.Hidden = true
|
||
|
||
utilityCommands := []*cobra.Command{
|
||
newAuthCommand(),
|
||
newAPICommand(flags),
|
||
newSkillCommand(),
|
||
newCacheCommand(),
|
||
newConfigCommand(),
|
||
newDoctorCommand(),
|
||
newCompletionCommand(root),
|
||
newRecoveryCommand(rootCtx, loader, flags),
|
||
newUpgradeCommand(),
|
||
newVersionCommand(),
|
||
newPluginCommand(),
|
||
schemaCmd,
|
||
genSkillsCmd,
|
||
mcpCmd,
|
||
}
|
||
root.AddCommand(utilityCommands...)
|
||
|
||
root.AddCommand(newLegacyPublicCommands(rootCtx, runner)...)
|
||
root.AddCommand(newLegacyHiddenCommands(runner)...)
|
||
|
||
// --- Plugin loading: runs AFTER legacy commands so that
|
||
// AppendDynamicServer adds plugin endpoints on top of Market
|
||
// endpoints (SetDynamicServers is called inside loadDynamicCommands).
|
||
pluginCmds := loadPlugins(engine, runner)
|
||
if len(pluginCmds) > 0 {
|
||
addPluginCommandsSafe(root, pluginCmds)
|
||
}
|
||
|
||
// PAT authorization commands (open-source core)
|
||
patCaller := newToolCallerAdapter(runner, flags)
|
||
pat.RegisterCommands(root, patCaller)
|
||
|
||
if fn := edition.Get().RegisterExtraCommands; fn != nil {
|
||
caller := newToolCallerAdapter(runner, flags)
|
||
fn(root, caller)
|
||
deduplicateCommands(root)
|
||
}
|
||
|
||
hideNonDirectRuntimeCommands(root)
|
||
configureRootHelp(root)
|
||
// Set custom flag error handler for better UX
|
||
root.SetFlagErrorFunc(flagErrorWithSuggestions)
|
||
root.SetContext(rootCtx)
|
||
|
||
return root
|
||
}
|
||
|
||
func newAuthCommand() *cobra.Command {
|
||
return buildAuthCommand()
|
||
}
|
||
|
||
func newSkillCommand() *cobra.Command {
|
||
return buildSkillCommand()
|
||
}
|
||
|
||
func newCacheCommand() *cobra.Command {
|
||
cacheCmd := newPlaceholderParent("cache", "缓存管理")
|
||
|
||
statusCmd := &cobra.Command{
|
||
Use: "status",
|
||
Short: "查看缓存状态",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
jsonOut, err := cmd.Flags().GetBool("json")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read cache status flags")
|
||
}
|
||
|
||
store := cacheStoreFromEnv()
|
||
files, bytes, err := cacheDirectoryStats(store.Root)
|
||
if err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to read cache status: %v", err))
|
||
}
|
||
|
||
// Enumerate per-server tools cache entries.
|
||
partition := config.DefaultPartition
|
||
entries, _ := store.ListToolsCacheEntries(partition)
|
||
|
||
payload := map[string]any{
|
||
"kind": "cache_status",
|
||
"cache_root": store.Root,
|
||
"files": files,
|
||
"bytes": bytes,
|
||
}
|
||
if len(entries) > 0 {
|
||
toolEntries := make([]map[string]any, 0, len(entries))
|
||
for _, e := range entries {
|
||
toolEntries = append(toolEntries, map[string]any{
|
||
"server_key": e.ServerKey,
|
||
"freshness": string(e.Freshness),
|
||
"saved_at": e.SavedAt.Format(time.RFC3339),
|
||
"tool_count": e.ToolCount,
|
||
"ttl_remaining": e.TTLRemaining,
|
||
})
|
||
}
|
||
payload["tools"] = toolEntries
|
||
}
|
||
|
||
if jsonOut {
|
||
return output.WriteJSON(cmd.OutOrStdout(), payload)
|
||
}
|
||
|
||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "缓存目录: %s\n文件数: %d 大小: %d 字节\n", store.Root, files, bytes)
|
||
if len(entries) > 0 {
|
||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\n工具缓存:")
|
||
for _, e := range entries {
|
||
age := ""
|
||
if !e.SavedAt.IsZero() {
|
||
dur := time.Since(e.SavedAt).Truncate(time.Minute)
|
||
age = fmt.Sprintf(",%s 前保存", dur)
|
||
}
|
||
ttl := ""
|
||
if e.TTLRemaining != "" {
|
||
ttl = fmt.Sprintf(",剩余 TTL %s", e.TTLRemaining)
|
||
}
|
||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s (%s%s,%d 个工具%s)\n",
|
||
e.ServerKey, string(e.Freshness), age, e.ToolCount, ttl)
|
||
}
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
statusCmd.Flags().Bool("json", false, "Emit cache status as JSON")
|
||
|
||
refreshCmd := &cobra.Command{
|
||
Use: "refresh",
|
||
Short: "强制刷新工具缓存",
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
product, err := cmd.Flags().GetString("product")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read cache refresh flags")
|
||
}
|
||
|
||
store := cacheStoreFromEnv()
|
||
transportClient := transport.NewClient(nil)
|
||
transportClient.AuthToken = resolveRuntimeAuthToken(cmd.Context(), "")
|
||
// Market client here is only a fallback for Detail API calls inside
|
||
// DiscoverAllRuntime; the primary server-list fetch below goes
|
||
// through fetchRegistryServers so edition DiscoveryURL wins.
|
||
service := discovery.NewService(
|
||
market.NewClient(DiscoveryBaseURL(), nil),
|
||
transportClient,
|
||
store,
|
||
)
|
||
|
||
resp, err := fetchRegistryServers(cmd.Context(), ipv4HTTPClient(config.HTTPTimeout))
|
||
if err != nil {
|
||
return apperrors.NewDiscovery(fmt.Sprintf("cache refresh: fetch server list failed: %v", err))
|
||
}
|
||
servers := market.NormalizeServersForBaseURL(resp, "live_market", registryDiscoveryBaseURL())
|
||
_ = store.SaveRegistry(service.CachePartition(), cache.RegistrySnapshot{Servers: servers})
|
||
|
||
selected := selectServersForProduct(servers, product)
|
||
if strings.TrimSpace(product) != "" && len(selected) == 0 {
|
||
return apperrors.NewValidation(fmt.Sprintf("no market server matched product %q", product))
|
||
}
|
||
if len(selected) == 0 {
|
||
selected = servers
|
||
}
|
||
|
||
if err := clearRuntimeCacheForServers(store, service.CachePartition(), selected); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to clear cache before refresh: %v", err))
|
||
}
|
||
|
||
refreshable := filterRefreshableServers(selected)
|
||
_, failures := service.DiscoverAllRuntime(cmd.Context(), refreshable)
|
||
_, err = fmt.Fprintf(
|
||
cmd.OutOrStdout(),
|
||
"[OK] 缓存刷新完成:已刷新 %d 个服务,失败 %d 个\n缓存目录: %s\n",
|
||
len(refreshable),
|
||
len(failures),
|
||
store.Root,
|
||
)
|
||
return err
|
||
},
|
||
}
|
||
refreshCmd.Flags().String("product", "", "Refresh only the selected canonical product")
|
||
_ = refreshCmd.Flags().MarkHidden("product")
|
||
|
||
cleanCmd := &cobra.Command{
|
||
Use: "clean",
|
||
Short: "清理缓存",
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
staleOnly, err := cmd.Flags().GetBool("stale")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read cache clean stale flag")
|
||
}
|
||
product, err := cmd.Flags().GetString("product")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read cache clean product flag")
|
||
}
|
||
|
||
store := cacheStoreFromEnv()
|
||
removed, err := cleanCacheFiles(store.Root, product, staleOnly)
|
||
if err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to clean cache: %v", err))
|
||
}
|
||
_, err = fmt.Fprintf(
|
||
cmd.OutOrStdout(),
|
||
"[OK] 缓存清理完成:已删除 %d 个文件\n",
|
||
removed,
|
||
)
|
||
return err
|
||
},
|
||
}
|
||
cleanCmd.Flags().Bool("stale", false, "Only remove stale cache entries")
|
||
cleanCmd.Flags().String("product", "", "Clean only the selected canonical product")
|
||
cleanCmd.Hidden = true
|
||
|
||
cacheCmd.AddCommand(statusCmd, refreshCmd, cleanCmd)
|
||
return cacheCmd
|
||
}
|
||
|
||
func newVersionCommand() *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "version",
|
||
Short: "显示版本信息",
|
||
Example: " dws version\n dws version --format json",
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
wantJSON := cmd.Flags().Changed("format")
|
||
if wantJSON {
|
||
format, _ := cmd.Flags().GetString("format")
|
||
wantJSON = (format == "json")
|
||
}
|
||
|
||
editionName := edition.Get().Name
|
||
if editionName == "" {
|
||
editionName = "open"
|
||
}
|
||
ver := RawVersion()
|
||
bt := BuildTime()
|
||
gc := GitCommit()
|
||
goVer := "1.24+"
|
||
|
||
arch := "MCP Dynamic Aggregation"
|
||
|
||
if wantJSON {
|
||
payload := map[string]any{
|
||
"version": ver,
|
||
"edition": editionName,
|
||
"architecture": arch,
|
||
"go": goVer,
|
||
}
|
||
if bt != "unknown" {
|
||
payload["build"] = bt
|
||
}
|
||
if gc != "unknown" {
|
||
payload["commit"] = gc
|
||
}
|
||
return output.WriteJSON(cmd.OutOrStdout(), payload)
|
||
}
|
||
|
||
w := cmd.OutOrStdout()
|
||
fmt.Fprintf(w, "%-16s%s\n", "Version:", ver)
|
||
fmt.Fprintf(w, "%-16s%s\n", "Edition:", editionName)
|
||
if bt != "unknown" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "Build:", bt)
|
||
}
|
||
if gc != "unknown" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "Commit:", gc)
|
||
}
|
||
fmt.Fprintf(w, "%-16s%s\n", "Architecture:", arch)
|
||
fmt.Fprintf(w, "%-16s%s\n", "Go:", goVer)
|
||
return nil
|
||
},
|
||
}
|
||
}
|
||
|
||
func newSchemaCommand(loader cli.CatalogLoader) *cobra.Command {
|
||
return cli.NewSchemaCommand(loader, newHelperToolFetcher())
|
||
}
|
||
|
||
func newGenerateSkillsCommand() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "generate-skills",
|
||
Short: "Generate agent skills from canonical metadata",
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
source, err := cmd.Flags().GetString("source")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read generate-skills source flag")
|
||
}
|
||
outputRoot, err := cmd.Flags().GetString("output-root")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read generate-skills output-root flag")
|
||
}
|
||
withDocs, err := cmd.Flags().GetBool("with-docs")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read generate-skills with-docs flag")
|
||
}
|
||
fixture, err := cmd.Flags().GetString("fixture")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read generate-skills fixture flag")
|
||
}
|
||
snapshot, err := cmd.Flags().GetString("snapshot")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read generate-skills snapshot flag")
|
||
}
|
||
catalogPath := fixture
|
||
if strings.EqualFold(strings.TrimSpace(source), string(generator.CatalogSourceSnapshot)) {
|
||
catalogPath = snapshot
|
||
}
|
||
for flagName, raw := range map[string]string{
|
||
"--output-root": outputRoot,
|
||
"--fixture": fixture,
|
||
"--snapshot": snapshot,
|
||
} {
|
||
if err := validateOptionalPath(flagName, raw); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
catalog, err := generator.LoadCatalogWithSource(cmd.Context(), source, catalogPath)
|
||
if err != nil {
|
||
return apperrors.NewDiscovery(fmt.Sprintf("failed to load canonical catalog: %v", err))
|
||
}
|
||
artifacts, err := generator.Generate(catalog)
|
||
if err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to generate skill artifacts: %v", err))
|
||
}
|
||
|
||
if withDocs {
|
||
if err := generator.WriteArtifacts(outputRoot, artifacts); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to write generated artifacts: %v", err))
|
||
}
|
||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "generated %d artifact(s) in %s\n", len(artifacts), outputRoot)
|
||
return err
|
||
}
|
||
|
||
targets := make([]generator.Artifact, 0)
|
||
for _, artifact := range artifacts {
|
||
if !strings.HasPrefix(artifact.Path, "skills/") {
|
||
continue
|
||
}
|
||
targets = append(targets, artifact)
|
||
}
|
||
if len(targets) == 0 {
|
||
return apperrors.NewInternal("no generated skill artifacts were produced")
|
||
}
|
||
if err := generator.WriteArtifacts(outputRoot, targets); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to write generated skills: %v", err))
|
||
}
|
||
|
||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "generated %d skill artifact(s) in %s\n", len(targets), outputRoot)
|
||
return err
|
||
},
|
||
}
|
||
cmd.Flags().String("output-root", ".", "Directory root for generated artifacts")
|
||
cmd.Flags().Bool("with-docs", true, "Write docs/schema artifacts in addition to skills")
|
||
cmd.Flags().String("source", string(generator.CatalogSourceFixture), "Catalog source for skill generation: fixture, env, or snapshot")
|
||
cmd.Flags().String("fixture", "", "Optional path to a catalog fixture; used by --source fixture")
|
||
cmd.Flags().String("snapshot", "", "Optional path to a catalog snapshot; used by --source snapshot")
|
||
return cmd
|
||
}
|
||
|
||
// buildMCPCommandFn is a test seam for newMCPCommand so a panic in the
|
||
// catalog-driven canonical build can be simulated without crafting a
|
||
// poisoned on-disk cache.
|
||
var buildMCPCommandFn = cli.NewMCPCommand
|
||
|
||
// newMCPCommand builds the canonical `dws mcp` tree, self-healing a poisoned
|
||
// cache when the build panics and degrading to an inert stub if that also
|
||
// fails.
|
||
//
|
||
// Why this guard exists: the canonical tree is assembled from cached catalog
|
||
// data BEFORE the legacy command build and before Cobra dispatches anything,
|
||
// so a panic here (e.g. a tool schema property named after the reserved
|
||
// --params flag, as cached during the 1.0.32 incident) used to abort every
|
||
// invocation — including `dws cache refresh` and `dws upgrade` — and was NOT
|
||
// covered by the legacy-path guards (#447/#452). Same two-staged recovery as
|
||
// buildEnvelopeCommandsSafe: quarantine the partition, retry once against a
|
||
// fresh fetch, then degrade with a `dws cache refresh` hint.
|
||
func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
|
||
cmd, panicked := tryBuildMCPCommand(ctx, loader, runner, engine)
|
||
if panicked == nil {
|
||
return cmd
|
||
}
|
||
slog.Error("newMCPCommand: canonical command build panicked", "panic", panicked)
|
||
|
||
quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
|
||
if qErr != nil {
|
||
slog.Error("newMCPCommand: failed to quarantine discovery cache", "error", qErr)
|
||
}
|
||
if quarantined != "" {
|
||
fmt.Fprintf(os.Stderr,
|
||
"Warning: building canonical commands from the local discovery cache failed: %v\n"+
|
||
"The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
|
||
panicked, quarantined)
|
||
cmd, panicked = tryBuildMCPCommand(ctx, loader, runner, engine)
|
||
if panicked == nil {
|
||
fmt.Fprintln(os.Stderr, "Canonical commands rebuilt successfully.")
|
||
return cmd
|
||
}
|
||
slog.Error("newMCPCommand: rebuild after cache quarantine panicked again, degrading to a stub", "panic", panicked)
|
||
}
|
||
|
||
fmt.Fprintf(os.Stderr,
|
||
"Warning: building canonical commands from the local discovery cache failed: %v\n"+
|
||
"The 'dws mcp' surface is temporarily unavailable; other commands still work.\n"+
|
||
"Run 'dws cache refresh' to rebuild the cache.\n", panicked)
|
||
buildErr := apperrors.NewInternal(fmt.Sprintf("canonical command build failed: %v; run 'dws cache refresh'", panicked))
|
||
stub := &cobra.Command{
|
||
Use: "mcp",
|
||
Short: "Canonical MCP-derived CLI surface (unavailable)",
|
||
Hidden: true,
|
||
Args: cobra.ArbitraryArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return buildErr
|
||
},
|
||
}
|
||
return stub
|
||
}
|
||
|
||
// tryBuildMCPCommand runs one attempt of the canonical build, converting a
|
||
// panic into a return value so the caller can decide between self-heal and
|
||
// degradation.
|
||
func tryBuildMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) (cmd *cobra.Command, panicked any) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
cmd = nil
|
||
panicked = r
|
||
}
|
||
}()
|
||
return buildMCPCommandFn(ctx, loader, runner, engine), nil
|
||
}
|
||
|
||
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
|
||
// unless they correspond to a product discovered via dynamic server discovery
|
||
// or listed in the edition's VisibleProducts hook.
|
||
// Public utility commands (auth, cache, completion, version) are always kept
|
||
// visible; explicitly hidden commands stay hidden.
|
||
func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||
allowedProducts := resolveVisibleProducts()
|
||
staticCommands := map[string]bool{
|
||
"auth": true,
|
||
"api": true,
|
||
"cache": true,
|
||
"config": true,
|
||
"doctor": true,
|
||
"completion": true,
|
||
"skill": true,
|
||
"plugin": true,
|
||
"version": true,
|
||
"help": true,
|
||
"recovery": true,
|
||
"schema": true,
|
||
"mcp": true,
|
||
}
|
||
for _, cmd := range root.Commands() {
|
||
name := cmd.Name()
|
||
if cmd.Hidden {
|
||
continue
|
||
}
|
||
if staticCommands[name] {
|
||
continue
|
||
}
|
||
if allowedProducts[name] {
|
||
continue
|
||
}
|
||
cmd.Hidden = true
|
||
}
|
||
}
|
||
|
||
// reservedCommands is the set of built-in command names that plugins must
|
||
// not override. This protects core CLI functionality from being hijacked
|
||
// by a malicious or misconfigured plugin.
|
||
var reservedCommands = map[string]bool{
|
||
"auth": true, "api": true, "login": true, "logout": true,
|
||
"plugin": true, "skill": true, "cache": true,
|
||
"config": true, "doctor": true, "completion": true,
|
||
"recovery": true, "upgrade": true, "version": true,
|
||
"schema": true, "mcp": true, "help": true,
|
||
}
|
||
|
||
// addPluginCommandsSafe registers plugin commands with conflict detection.
|
||
//
|
||
// Rules:
|
||
// - Plugin vs reserved (auth/plugin/cache/...) → reject, warn
|
||
// - Plugin vs plugin (same name) → reject later one, warn
|
||
// - Plugin vs Market dynamic command → allow, plugin wins
|
||
func addPluginCommandsSafe(root *cobra.Command, pluginCmds []*cobra.Command) {
|
||
// Build index of existing commands before plugin registration.
|
||
existing := make(map[string]bool)
|
||
for _, cmd := range root.Commands() {
|
||
existing[cmd.Name()] = true
|
||
}
|
||
|
||
pluginSeen := make(map[string]bool)
|
||
|
||
for _, cmd := range pluginCmds {
|
||
name := cmd.Name()
|
||
|
||
// Rule 1: never override reserved built-in commands.
|
||
if reservedCommands[name] {
|
||
slog.Warn("plugin: command name conflicts with built-in command, skipping",
|
||
"command", name)
|
||
continue
|
||
}
|
||
|
||
// Rule 2: plugin vs plugin — first plugin wins.
|
||
if pluginSeen[name] {
|
||
slog.Warn("plugin: duplicate command from another plugin, skipping",
|
||
"command", name)
|
||
continue
|
||
}
|
||
pluginSeen[name] = true
|
||
|
||
// Rule 3: plugin vs Market — plugin wins, remove the old one.
|
||
if existing[name] {
|
||
for _, old := range root.Commands() {
|
||
if old.Name() == name {
|
||
root.RemoveCommand(old)
|
||
slog.Debug("plugin: overriding Market command",
|
||
"command", name)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
root.AddCommand(cmd)
|
||
}
|
||
}
|
||
|
||
// deduplicateCommands removes duplicate top-level commands, keeping the last
|
||
// registered one. This ensures overlay commands take precedence over
|
||
// open-source defaults when both register the same product name.
|
||
func deduplicateCommands(root *cobra.Command) {
|
||
seen := make(map[string]*cobra.Command)
|
||
var dups []*cobra.Command
|
||
for _, cmd := range root.Commands() {
|
||
name := cmd.Name()
|
||
if prev, ok := seen[name]; ok {
|
||
dups = append(dups, prev)
|
||
}
|
||
seen[name] = cmd
|
||
}
|
||
for _, dup := range dups {
|
||
root.RemoveCommand(dup)
|
||
}
|
||
}
|
||
|
||
func cacheStoreFromEnv() *cache.Store {
|
||
cacheDir := strings.TrimSpace(os.Getenv(cli.CacheDirEnv))
|
||
return cache.NewStore(cacheDir)
|
||
}
|
||
|
||
// pluginColdTimeouts holds the cold-path discovery budget for plugin MCP
|
||
// servers. Timeouts only apply to the *first* discovery for a given
|
||
// plugin/server; subsequent startups take the warm cache path and bypass
|
||
// the network entirely.
|
||
type pluginColdTimeouts struct {
|
||
httpNoAuth time.Duration
|
||
httpAuth time.Duration
|
||
stdio time.Duration
|
||
}
|
||
|
||
// resolvePluginColdTimeouts returns the cold-discovery budget for plugin MCP
|
||
// servers, applying the DWS_PLUGIN_COLD_TIMEOUT override when set. Defaults
|
||
// are tuned so healthy cross-region HTTP endpoints succeed on a cold start
|
||
// and Python/Node-based stdio plugins have headroom for interpreter load,
|
||
// while an unreachable host still surrenders in bounded time.
|
||
func resolvePluginColdTimeouts() pluginColdTimeouts {
|
||
t := pluginColdTimeouts{
|
||
httpNoAuth: 1 * time.Second,
|
||
httpAuth: 1500 * time.Millisecond,
|
||
stdio: 2 * time.Second,
|
||
}
|
||
raw := strings.TrimSpace(os.Getenv(cli.PluginColdTimeoutEnv))
|
||
if raw == "" {
|
||
return t
|
||
}
|
||
d, err := time.ParseDuration(raw)
|
||
if err != nil || d <= 0 {
|
||
slog.Warn("plugin: ignoring invalid DWS_PLUGIN_COLD_TIMEOUT",
|
||
"value", raw, "error", err)
|
||
return t
|
||
}
|
||
t.httpNoAuth = d
|
||
t.httpAuth = d
|
||
t.stdio = d
|
||
return t
|
||
}
|
||
|
||
func configureOutputSink(cmd *cobra.Command) error {
|
||
if local := cmd.LocalFlags().Lookup("output"); local != nil {
|
||
return nil
|
||
}
|
||
outputPath, err := cmd.Flags().GetString("output")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read output flag")
|
||
}
|
||
outputPath = strings.TrimSpace(outputPath)
|
||
if outputPath == "" {
|
||
return nil
|
||
}
|
||
if err := validateOptionalPath("--output", outputPath); err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
|
||
}
|
||
file, err := os.Create(outputPath)
|
||
if err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err))
|
||
}
|
||
cmd.SetOut(file)
|
||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, file))
|
||
return nil
|
||
}
|
||
|
||
func closeOutputSink(cmd *cobra.Command) error {
|
||
file, ok := cmd.Context().Value(outputFileContextKey{}).(*os.File)
|
||
if !ok || file == nil {
|
||
return nil
|
||
}
|
||
if err := file.Close(); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateOptionalPath(flagName, path string) error {
|
||
path = strings.TrimSpace(path)
|
||
if path == "" {
|
||
return nil
|
||
}
|
||
if err := apperrors.SafePath(path); err != nil {
|
||
return apperrors.NewValidation(fmt.Sprintf("%s contains an unsafe path: %v", flagName, err))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func cacheDirectoryStats(root string) (int, int64, error) {
|
||
if strings.TrimSpace(root) == "" {
|
||
return 0, 0, nil
|
||
}
|
||
if _, err := os.Stat(root); err != nil {
|
||
if os.IsNotExist(err) {
|
||
return 0, 0, nil
|
||
}
|
||
return 0, 0, err
|
||
}
|
||
|
||
files := 0
|
||
var bytes int64
|
||
err := filepath.WalkDir(root, func(entryPath string, d os.DirEntry, walkErr error) error {
|
||
if walkErr != nil {
|
||
return walkErr
|
||
}
|
||
if d.IsDir() {
|
||
return nil
|
||
}
|
||
info, err := d.Info()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
files++
|
||
bytes += info.Size()
|
||
return nil
|
||
})
|
||
return files, bytes, err
|
||
}
|
||
|
||
func selectServersForProduct(servers []market.ServerDescriptor, product string) []market.ServerDescriptor {
|
||
product = strings.TrimSpace(strings.ToLower(product))
|
||
if product == "" {
|
||
return servers
|
||
}
|
||
|
||
selected := make([]market.ServerDescriptor, 0)
|
||
for _, server := range servers {
|
||
candidates := []string{
|
||
strings.ToLower(strings.TrimSpace(server.DisplayName)),
|
||
strings.ToLower(strings.TrimSpace(server.Key)),
|
||
strings.ToLower(strings.TrimSpace(path.Base(server.Endpoint))),
|
||
}
|
||
for _, candidate := range candidates {
|
||
if candidate == "" {
|
||
continue
|
||
}
|
||
if candidate == product || strings.Contains(candidate, product) {
|
||
selected = append(selected, server)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return selected
|
||
}
|
||
|
||
func filterRefreshableServers(servers []market.ServerDescriptor) []market.ServerDescriptor {
|
||
filtered := make([]market.ServerDescriptor, 0, len(servers))
|
||
for _, server := range servers {
|
||
if server.CLI.Skip {
|
||
continue
|
||
}
|
||
filtered = append(filtered, server)
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
func clearRuntimeCacheForServers(store *cache.Store, partition string, servers []market.ServerDescriptor) error {
|
||
for _, server := range servers {
|
||
for _, cacheKey := range cacheKeysForServer(server) {
|
||
if err := store.DeleteTools(partition, cacheKey); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for _, cacheKey := range detailCacheKeysForServer(server) {
|
||
if err := store.DeleteDetail(partition, cacheKey); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func cacheKeysForServer(server market.ServerDescriptor) []string {
|
||
seen := make(map[string]struct{}, 2)
|
||
keys := make([]string, 0, 2)
|
||
for _, candidate := range []string{
|
||
strings.TrimSpace(server.Key),
|
||
strings.TrimSpace(server.CLI.ID),
|
||
} {
|
||
if candidate == "" {
|
||
continue
|
||
}
|
||
if _, ok := seen[candidate]; ok {
|
||
continue
|
||
}
|
||
seen[candidate] = struct{}{}
|
||
keys = append(keys, candidate)
|
||
}
|
||
return keys
|
||
}
|
||
|
||
func detailCacheKeysForServer(server market.ServerDescriptor) []string {
|
||
key := strings.TrimSpace(server.Key)
|
||
if key != "" {
|
||
return []string{key}
|
||
}
|
||
id := strings.TrimSpace(server.CLI.ID)
|
||
if id != "" {
|
||
return []string{id}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func cleanCacheFiles(root, product string, staleOnly bool) (int, error) {
|
||
if strings.TrimSpace(root) == "" {
|
||
return 0, nil
|
||
}
|
||
if _, err := os.Stat(root); err != nil {
|
||
if os.IsNotExist(err) {
|
||
return 0, nil
|
||
}
|
||
return 0, err
|
||
}
|
||
|
||
staleCutoff := time.Now().UTC().Add(-cache.ToolsTTL)
|
||
product = strings.TrimSpace(strings.ToLower(product))
|
||
removed := 0
|
||
|
||
err := filepath.WalkDir(root, func(entryPath string, d os.DirEntry, walkErr error) error {
|
||
if walkErr != nil {
|
||
return walkErr
|
||
}
|
||
if d.IsDir() {
|
||
return nil
|
||
}
|
||
|
||
normalizedPath := strings.ToLower(filepath.ToSlash(entryPath))
|
||
if product != "" && !strings.Contains(normalizedPath, product) {
|
||
return nil
|
||
}
|
||
|
||
info, err := d.Info()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if staleOnly && info.ModTime().After(staleCutoff) {
|
||
return nil
|
||
}
|
||
if err := os.Remove(entryPath); err != nil && !os.IsNotExist(err) {
|
||
return err
|
||
}
|
||
removed++
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return removed, nil
|
||
}
|
||
|
||
// fileLogger holds the package-level file logger for diagnostics.
|
||
// It is initialized by configureLogLevel and closed by CloseFileLogger.
|
||
var fileLogger *logging.FileLogger
|
||
|
||
// configureLogLevel sets the global slog level based on --debug and --verbose flags
|
||
// and initializes the file logger for diagnostics.
|
||
// --debug → slog.LevelDebug; --verbose → slog.LevelInfo; default → slog.LevelWarn.
|
||
func configureLogLevel(flags *GlobalFlags) {
|
||
if flags == nil {
|
||
return
|
||
}
|
||
var level slog.Level
|
||
switch {
|
||
case flags.Debug:
|
||
level = slog.LevelDebug
|
||
case flags.Verbose:
|
||
level = slog.LevelInfo
|
||
default:
|
||
level = slog.LevelWarn
|
||
}
|
||
stderrHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})
|
||
|
||
// Initialize file logger — writes to ~/.dws/logs/dws.log at DEBUG level
|
||
// regardless of stderr level. All slog calls are captured for diagnostics.
|
||
fileLogger = logging.Setup(defaultConfigDir())
|
||
fileHandler := slog.NewJSONHandler(fileLogger.Writer(), &slog.HandlerOptions{Level: slog.LevelDebug})
|
||
|
||
slog.SetDefault(slog.New(logging.NewMultiHandler(stderrHandler, fileHandler)))
|
||
}
|
||
|
||
// FileLoggerInstance returns the package-level file logger, or nil if not initialized.
|
||
func FileLoggerInstance() *slog.Logger {
|
||
if fileLogger == nil {
|
||
return nil
|
||
}
|
||
return fileLogger.Logger
|
||
}
|
||
|
||
// CloseFileLogger flushes and closes the file logger.
|
||
func CloseFileLogger() {
|
||
if fileLogger != nil {
|
||
fileLogger.Close()
|
||
}
|
||
}
|
||
|
||
// loadPlugins scans plugin directories, injects their MCP servers into
|
||
// the dynamic server registry, and registers their pipeline hooks.
|
||
// This runs before legacy command construction so that plugin servers
|
||
// are available for EnvironmentLoader.Load().
|
||
func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Command {
|
||
pluginLoader := plugin.NewLoader(RawVersion())
|
||
|
||
// 0a. Inject plugin config values from settings.json as environment
|
||
// variables so that expandPluginVars can resolve ${KEY} references
|
||
// in plugin.json headers, endpoints, etc. User-set env vars take
|
||
// precedence (InjectPluginConfigEnv skips already-set keys).
|
||
pluginLoader.InjectPluginConfigEnv()
|
||
|
||
// Load TokenData once; reused for stdio injection below.
|
||
tokenData, _ := authpkg.LoadTokenData(defaultConfigDir())
|
||
var userCtx *plugin.UserContext
|
||
if tokenData != nil {
|
||
// Inject user context if either UserID or CorpID is present.
|
||
if tokenData.UserID != "" || tokenData.CorpID != "" {
|
||
userCtx = &plugin.UserContext{
|
||
UserID: tokenData.UserID,
|
||
CorpID: tokenData.CorpID,
|
||
}
|
||
}
|
||
}
|
||
|
||
// 1. Load user plugins (per settings.json)
|
||
userPlugins := pluginLoader.LoadUser()
|
||
|
||
// 2. Load dev plugins (registered via `dws plugin dev`)
|
||
devPlugins := pluginLoader.LoadDev()
|
||
|
||
allPlugins := append(userPlugins, devPlugins...)
|
||
|
||
// 3. Discover tools from streamable-http servers and build CLI commands.
|
||
// Third-party servers with auth headers are discovered in parallel
|
||
// to avoid sequential 10s timeouts when multiple remote servers exist.
|
||
var pluginCmds []*cobra.Command
|
||
tc := transport.NewClient(nil)
|
||
|
||
// Collect all server descriptors and register auth first (fast, no I/O).
|
||
type pluginServer struct {
|
||
plugin *plugin.Plugin
|
||
srv market.ServerDescriptor
|
||
}
|
||
var httpServers []pluginServer
|
||
|
||
for _, p := range allPlugins {
|
||
for _, srv := range p.ToServerDescriptors() {
|
||
AppendDynamicServer(srv)
|
||
|
||
if len(srv.AuthHeaders) > 0 {
|
||
registerPluginAuthFromHeaders(srv)
|
||
}
|
||
|
||
if srv.HasCLIMeta {
|
||
httpServers = append(httpServers, pluginServer{plugin: p, srv: srv})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Collect all stdio clients up front so HTTP + stdio discovery can run
|
||
// concurrently — the slowest plugin (typically an unreachable HTTP
|
||
// endpoint hitting its dial timeout) dominates the parallel wall-clock,
|
||
// not the sum of every plugin's cold timeout.
|
||
type stdioEntry struct {
|
||
plugin *plugin.Plugin
|
||
sc plugin.StdioServerClient
|
||
}
|
||
var stdioEntries []stdioEntry
|
||
for _, p := range allPlugins {
|
||
for _, sc := range p.StdioClients(userCtx) {
|
||
// Use background context so the subprocess lives for the CLI
|
||
// process lifetime (not killed by a short timeout).
|
||
if err := sc.Client.Start(context.Background()); err != nil {
|
||
slog.Warn("plugin: failed to start stdio server",
|
||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||
continue
|
||
}
|
||
stdioEntries = append(stdioEntries, stdioEntry{plugin: p, sc: sc})
|
||
}
|
||
}
|
||
|
||
// Share one cache.Store across all discovery goroutines. Each goroutine
|
||
// writes to a distinct serverKey path ("tools/<plugin>_<server>.json") with
|
||
// atomic tmp+rename, so concurrent writes to different keys never collide
|
||
// on the filesystem. Global in-process registries (AppendDynamicServer,
|
||
// RegisterStdioClient) carry their own sync.Mutex; see direct_runtime.go
|
||
// and stdio_registry.go.
|
||
sharedStore := cacheStoreFromEnv()
|
||
coldTimeouts := resolvePluginColdTimeouts()
|
||
|
||
// Phase A: stdio overlay-first registration (synchronous, no I/O).
|
||
// Plugins whose overlay.json declares ToolOverrides register their full
|
||
// command tree up-front from manifest metadata alone — no subprocess
|
||
// handshake required. This fixes the "discovery fails → no commands
|
||
// ever appear" lock-out and keeps `dws --help` reliable even when the
|
||
// underlying MCP server is temporarily unavailable.
|
||
var legacyStdioEntries []stdioEntry
|
||
for _, e := range stdioEntries {
|
||
cmds, _, ok := registerStdioServerFromOverlay(e.plugin, e.sc, runner, sharedStore)
|
||
if !ok {
|
||
legacyStdioEntries = append(legacyStdioEntries, e)
|
||
continue
|
||
}
|
||
pluginCmds = append(pluginCmds, cmds...)
|
||
}
|
||
|
||
// Phase B: fan out discovery in parallel.
|
||
// - HTTP plugins: same behaviour as before (discovery-first).
|
||
// - stdio overlay-first plugins: async cache refresh only; their
|
||
// commands are already registered. Failures are non-fatal and do
|
||
// NOT poison the warm-cache with a null-tools snapshot.
|
||
// - stdio legacy plugins (overlay without toolOverrides): preserve
|
||
// the old discovery-first path for backwards compatibility.
|
||
httpResults := make([][]*cobra.Command, len(httpServers))
|
||
legacyStdioResults := make([][]*cobra.Command, len(legacyStdioEntries))
|
||
var wg sync.WaitGroup
|
||
for i, ps := range httpServers {
|
||
wg.Add(1)
|
||
go func(idx int, ps pluginServer) {
|
||
defer wg.Done()
|
||
httpResults[idx] = registerHTTPServer(ps.plugin, ps.srv, tc, runner, sharedStore, coldTimeouts)
|
||
}(i, ps)
|
||
}
|
||
// overlay-first stdio: async refresh (no command building here).
|
||
for _, e := range stdioEntries {
|
||
if !hasOverlayToolOverrides(e.plugin, e.sc) {
|
||
continue
|
||
}
|
||
wg.Add(1)
|
||
go func(e stdioEntry) {
|
||
defer wg.Done()
|
||
refreshStdioToolsCache(e.plugin, e.sc, sharedStore, coldTimeouts)
|
||
}(e)
|
||
}
|
||
// legacy stdio: discovery-first (commands depend on tool list).
|
||
for i, e := range legacyStdioEntries {
|
||
wg.Add(1)
|
||
go func(idx int, e stdioEntry) {
|
||
defer wg.Done()
|
||
legacyStdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, sharedStore, coldTimeouts)
|
||
}(i, e)
|
||
}
|
||
wg.Wait()
|
||
for _, cmds := range httpResults {
|
||
pluginCmds = append(pluginCmds, cmds...)
|
||
}
|
||
for _, cmds := range legacyStdioResults {
|
||
pluginCmds = append(pluginCmds, cmds...)
|
||
}
|
||
|
||
// 5. Register plugin hooks into pipeline engine
|
||
if engine != nil {
|
||
for _, p := range allPlugins {
|
||
hooksCfg, err := p.LoadHooks()
|
||
if err != nil {
|
||
slog.Warn("plugin: failed to load hooks",
|
||
"plugin", p.Manifest.Name, "error", err)
|
||
continue
|
||
}
|
||
if hooksCfg == nil {
|
||
continue
|
||
}
|
||
for _, entry := range hooksCfg.Hooks {
|
||
engine.Register(plugin.NewHookAdapter(p.Manifest.Name, entry))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 7. Sync plugin skills to agent directories
|
||
plugin.SyncSkills(allPlugins)
|
||
|
||
if len(allPlugins) > 0 {
|
||
slog.Debug("plugins loaded",
|
||
"user", len(userPlugins),
|
||
"dev", len(devPlugins),
|
||
)
|
||
}
|
||
|
||
return pluginCmds
|
||
}
|
||
|
||
// pluginCacheKey derives the cache key used to persist a plugin MCP server's
|
||
// tool list. Prefixed with "plugin:" so entries are namespaced apart from the
|
||
// Market-derived cache, and visible distinctly via `dws cache status`.
|
||
func pluginCacheKey(pluginName, serverKey string) string {
|
||
return "plugin:" + pluginName + ":" + serverKey
|
||
}
|
||
|
||
// registerHTTPServer discovers tools from a streamable-http MCP server and
|
||
// builds CLI commands. Used for plugin-owned HTTP servers that provide CLI metadata.
|
||
//
|
||
// Startup-latency strategy (issue #119):
|
||
// - Warm cache: build commands from the persisted tools snapshot
|
||
// synchronously — no network I/O. `dws --help` returns in ms even when
|
||
// the plugin endpoint is unreachable.
|
||
// - Cold cache: synchronous discovery (Initialize + ListTools) with a tight
|
||
// timeout. The outcome — success or failure — is persisted so the next
|
||
// invocation hits the warm path. Refresh on demand via `dws cache clean`
|
||
// / `dws cache refresh`; the cache TTL (7d) otherwise expires naturally.
|
||
//
|
||
// When the server descriptor carries AuthHeaders (from plugin.json "headers"),
|
||
// a dedicated transport.Client is created with the plugin's Bearer token and
|
||
// trusted domains so that third-party MCP servers requiring independent
|
||
// authentication (e.g. Alibaba Cloud Bailian) can be discovered at startup.
|
||
func registerHTTPServer(p *plugin.Plugin, srv market.ServerDescriptor, tc *transport.Client, runner executor.Runner, store *cache.Store, timeouts pluginColdTimeouts) []*cobra.Command {
|
||
partition := config.DefaultPartition
|
||
cacheKey := pluginCacheKey(p.Manifest.Name, srv.Key)
|
||
|
||
if snapshot, freshness, err := store.LoadTools(partition, cacheKey); err == nil {
|
||
slog.Debug("plugin: http server served from cache",
|
||
"plugin", p.Manifest.Name, "server", srv.Key,
|
||
"tools", len(snapshot.Tools), "freshness", string(freshness))
|
||
return buildHTTPCommandsFromTools(srv, snapshot.Tools, runner)
|
||
}
|
||
|
||
// Cold cache: synchronous discovery. Persist the outcome even on failure
|
||
// (empty tools == negative cache) so the next invocation takes the fast
|
||
// path regardless of endpoint health.
|
||
tools := discoverHTTPTools(p, srv, tc, timeouts)
|
||
_ = store.SaveTools(partition, cacheKey, cache.ToolsSnapshot{
|
||
ServerKey: cacheKey,
|
||
Tools: tools,
|
||
})
|
||
return buildHTTPCommandsFromTools(srv, tools, runner)
|
||
}
|
||
|
||
// discoverHTTPTools performs the blocking Initialize + ListTools handshake
|
||
// for an HTTP MCP server and returns the discovered tools. Returns nil on
|
||
// any transport/protocol error; errors are logged at Debug level.
|
||
func discoverHTTPTools(p *plugin.Plugin, srv market.ServerDescriptor, tc *transport.Client, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
|
||
// Cold-path budget. An unreachable endpoint will burn the full window
|
||
// via the TCP dial timeout; a healthy localhost/third-party endpoint
|
||
// typically responds in <200 ms. Third-party servers with auth get a
|
||
// slightly larger window to accommodate TLS + auth RTT. Operators with
|
||
// cross-region endpoints can relax the window via DWS_PLUGIN_COLD_TIMEOUT.
|
||
// The outcome is persisted as a negative cache so subsequent startups
|
||
// (80 ms warm) are unaffected. See issue #119.
|
||
timeout := timeouts.httpNoAuth
|
||
if len(srv.AuthHeaders) > 0 {
|
||
timeout = timeouts.httpAuth
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||
defer cancel()
|
||
|
||
discoveryClient := tc
|
||
if len(srv.AuthHeaders) > 0 {
|
||
discoveryClient = buildPluginAuthClient(tc, srv)
|
||
}
|
||
|
||
if _, err := discoveryClient.Initialize(ctx, srv.Endpoint); err != nil {
|
||
slog.Debug("plugin: http server offline, skipping tool discovery",
|
||
"plugin", p.Manifest.Name, "server", srv.Key)
|
||
return nil
|
||
}
|
||
|
||
toolsResult, err := discoveryClient.ListTools(ctx, srv.Endpoint)
|
||
if err != nil {
|
||
slog.Debug("plugin: http ListTools failed",
|
||
"plugin", p.Manifest.Name, "server", srv.Key, "error", err)
|
||
return nil
|
||
}
|
||
return toolsResult.Tools
|
||
}
|
||
|
||
// buildHTTPCommandsFromTools converts a tool list into Cobra commands via
|
||
// the BuildDynamicCommands path. Returns nil for an empty tool list.
|
||
func buildHTTPCommandsFromTools(srv market.ServerDescriptor, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
|
||
if len(tools) == 0 {
|
||
return nil
|
||
}
|
||
|
||
detailsByID := make(map[string][]market.DetailTool)
|
||
var detailTools []market.DetailTool
|
||
for _, tool := range tools {
|
||
schemaJSON := ""
|
||
if tool.InputSchema != nil {
|
||
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
|
||
schemaJSON = string(data)
|
||
}
|
||
}
|
||
detailTools = append(detailTools, market.DetailTool{
|
||
ToolName: tool.Name,
|
||
ToolTitle: tool.Title,
|
||
ToolDesc: tool.Description,
|
||
IsSensitive: tool.Sensitive,
|
||
ToolRequest: schemaJSON,
|
||
})
|
||
}
|
||
detailsByID[strings.TrimSpace(srv.CLI.ID)] = detailTools
|
||
|
||
// If the server has no ToolOverrides (e.g. third-party MCP servers that
|
||
// only declare cli.id and cli.command), auto-generate one override per
|
||
// discovered tool so BuildDynamicCommands can create leaf commands.
|
||
if len(srv.CLI.ToolOverrides) == 0 {
|
||
srv.CLI.ToolOverrides = make(map[string]market.CLIToolOverride, len(tools))
|
||
for _, tool := range tools {
|
||
srv.CLI.ToolOverrides[tool.Name] = market.CLIToolOverride{
|
||
CLIName: deriveToolCLIName(tool.Name),
|
||
}
|
||
}
|
||
}
|
||
|
||
return compat.BuildDynamicCommands(
|
||
[]market.ServerDescriptor{srv}, runner, detailsByID)
|
||
}
|
||
|
||
// deriveToolCLIName converts an MCP tool name (e.g. "web_search" or
|
||
// "maps.search_poi") into a kebab-case CLI command name ("search" or
|
||
// "search-poi"). It strips common prefixes and replaces underscores/dots
|
||
// with hyphens.
|
||
func deriveToolCLIName(toolName string) string {
|
||
// Use the last segment after "." as the base name.
|
||
if idx := strings.LastIndex(toolName, "."); idx >= 0 {
|
||
toolName = toolName[idx+1:]
|
||
}
|
||
// Replace underscores with hyphens for kebab-case.
|
||
return strings.ReplaceAll(toolName, "_", "-")
|
||
}
|
||
|
||
// buildPluginAuthClient creates a transport.Client copy with the plugin's
|
||
// Bearer token and trusted domains injected. This allows third-party MCP
|
||
// servers that require independent authentication to be discovered at startup.
|
||
func buildPluginAuthClient(base *transport.Client, srv market.ServerDescriptor) *transport.Client {
|
||
authToken := ""
|
||
extraHeaders := make(map[string]string)
|
||
for key, value := range srv.AuthHeaders {
|
||
if strings.EqualFold(key, "Authorization") {
|
||
authToken = strings.TrimPrefix(value, "Bearer ")
|
||
authToken = strings.TrimSpace(authToken)
|
||
} else {
|
||
extraHeaders[key] = value
|
||
}
|
||
}
|
||
if authToken == "" {
|
||
return base
|
||
}
|
||
client := base.WithAuth(authToken, extraHeaders)
|
||
// Trust the endpoint's hostname so the token is actually sent.
|
||
if parsed, err := url.Parse(srv.Endpoint); err == nil {
|
||
host := parsed.Hostname()
|
||
client.TrustedDomains = []string{host, "*." + host}
|
||
}
|
||
return client
|
||
}
|
||
|
||
// registerPluginAuthFromHeaders extracts authentication credentials from
|
||
// a server descriptor's AuthHeaders and registers them in the global
|
||
// PluginAuth registry. The runner uses this registry at execution time
|
||
// to inject the correct Bearer token for third-party MCP servers.
|
||
func registerPluginAuthFromHeaders(srv market.ServerDescriptor) {
|
||
authToken := ""
|
||
extraHeaders := make(map[string]string)
|
||
for key, value := range srv.AuthHeaders {
|
||
if strings.EqualFold(key, "Authorization") {
|
||
authToken = strings.TrimPrefix(value, "Bearer ")
|
||
authToken = strings.TrimSpace(authToken)
|
||
} else {
|
||
extraHeaders[key] = value
|
||
}
|
||
}
|
||
if authToken == "" {
|
||
return
|
||
}
|
||
var trustedDomains []string
|
||
if parsed, err := url.Parse(srv.Endpoint); err == nil {
|
||
host := parsed.Hostname()
|
||
trustedDomains = []string{host, "*." + host}
|
||
}
|
||
productID := strings.TrimSpace(srv.CLI.ID)
|
||
if productID == "" {
|
||
productID = srv.Key
|
||
}
|
||
RegisterPluginAuth(productID, &PluginAuth{
|
||
Token: authToken,
|
||
ExtraHeaders: extraHeaders,
|
||
TrustedDomains: trustedDomains,
|
||
})
|
||
}
|
||
|
||
// registerStdioServer initializes a stdio MCP server, discovers its tools
|
||
// via ListTools, builds CLI commands, and registers the StdioClient for
|
||
// runtime dispatch. Returns generated cobra commands.
|
||
//
|
||
// Warm-cache fast path (issue #119): when a tools snapshot is already cached
|
||
// for this plugin/server, skip the Initialize + ListTools RPC round-trip and
|
||
// rebuild commands directly from the snapshot. Cold cache falls back to
|
||
// synchronous discovery with a 4s cap and persists the outcome.
|
||
func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner executor.Runner, store *cache.Store, timeouts pluginColdTimeouts) []*cobra.Command {
|
||
partition := config.DefaultPartition
|
||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||
|
||
if snapshot, freshness, err := store.LoadTools(partition, cacheKey); err == nil {
|
||
slog.Debug("plugin: stdio server served from cache",
|
||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||
"tools", len(snapshot.Tools), "freshness", string(freshness))
|
||
return buildStdioCommands(p, sc, snapshot.Tools, runner)
|
||
}
|
||
|
||
tools := discoverStdioTools(p, sc, timeouts)
|
||
_ = store.SaveTools(partition, cacheKey, cache.ToolsSnapshot{
|
||
ServerKey: cacheKey,
|
||
Tools: tools,
|
||
})
|
||
return buildStdioCommands(p, sc, tools, runner)
|
||
}
|
||
|
||
// discoverStdioTools performs the blocking Initialize + ListTools handshake
|
||
// on a stdio MCP subprocess. Returns nil on any error (logged at Debug level).
|
||
// The default 2s budget comfortably accommodates Python/Node runtimes whose
|
||
// interpreter + dependency load dominates the first response. Operators with
|
||
// heavier startup chains can relax further via DWS_PLUGIN_COLD_TIMEOUT.
|
||
//
|
||
// A handshake failure here is an EXPECTED, benign outcome for an optional local
|
||
// plugin: e.g. the conference plugin reports "本地服务未就绪" whenever the
|
||
// DingTalk desktop client isn't running, which is the common case for anyone
|
||
// not actively recording a meeting. Discovery simply yields no tools and the
|
||
// run proceeds — commands that ship toolOverrides still register up-front via
|
||
// registerStdioServerFromOverlay (Phase A), so availability is unaffected.
|
||
//
|
||
// These run during command-tree construction (NewRootCommandWithEngine), which
|
||
// happens BEFORE PersistentPreRunE applies --debug/--verbose via
|
||
// configureLogLevel. So a Warn here printed to stderr on EVERY invocation
|
||
// regardless of flags, polluting output and misleading callers into treating it
|
||
// as the cause of an unrelated command error (e.g. an auth or PARAM_ERROR from a
|
||
// completely different server). Logging at Debug keeps the discovery miss out of
|
||
// normal output; surfacing it would require configuring the log level before the
|
||
// tree is built, which we deliberately avoid this close to release.
|
||
func discoverStdioTools(p *plugin.Plugin, sc plugin.StdioServerClient, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
|
||
ctx, cancel := context.WithTimeout(context.Background(), timeouts.stdio)
|
||
defer cancel()
|
||
|
||
if _, err := sc.Client.Initialize(ctx); err != nil {
|
||
slog.Debug("plugin: stdio initialize failed",
|
||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||
return nil
|
||
}
|
||
toolsResult, err := sc.Client.ListTools(ctx)
|
||
if err != nil {
|
||
slog.Debug("plugin: stdio ListTools failed",
|
||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||
return nil
|
||
}
|
||
return toolsResult.Tools
|
||
}
|
||
|
||
// buildStdioCommands constructs Cobra commands from a tool list and
|
||
// registers the runtime dispatch state (StdioClient + dynamic server).
|
||
// Returns nil for an empty tool list.
|
||
//
|
||
// This is the legacy discovery-first path, used only for stdio plugins whose
|
||
// overlay.json does NOT carry toolOverrides. Plugins that ship toolOverrides
|
||
// register commands up-front via registerStdioServerFromOverlay, bypassing
|
||
// this function entirely (see plugin_stdio_overlay.go).
|
||
func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
|
||
if len(tools) == 0 {
|
||
slog.Debug("plugin: stdio server has no tools",
|
||
"plugin", p.Manifest.Name, "server", sc.Key)
|
||
return nil
|
||
}
|
||
|
||
overlay := resolveStdioOverlay(p, sc)
|
||
|
||
// Auto-generate ToolOverrides from discovered tools when not provided
|
||
// by the manifest/overlay (legacy discovery-first path).
|
||
if len(overlay.ToolOverrides) == 0 {
|
||
overlay.ToolOverrides = make(map[string]market.CLIToolOverride)
|
||
if len(overlay.Prefixes) == 0 {
|
||
overlay.Prefixes = []string{overlay.ID}
|
||
}
|
||
for _, tool := range tools {
|
||
overlay.ToolOverrides[tool.Name] = market.CLIToolOverride{
|
||
IsSensitive: tool.Sensitive,
|
||
}
|
||
}
|
||
}
|
||
|
||
descriptor := market.ServerDescriptor{
|
||
Key: sc.Key,
|
||
DisplayName: p.Manifest.Name + "/" + sc.Key,
|
||
Description: p.Manifest.Description,
|
||
Endpoint: StdioEndpoint(p.Manifest.Name, sc.Key),
|
||
Source: "plugin",
|
||
CLI: overlay,
|
||
HasCLIMeta: true,
|
||
}
|
||
|
||
AppendDynamicServer(descriptor)
|
||
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
|
||
|
||
detailsByID := toolsToDetails(tools, overlay.ID)
|
||
cmds := compat.BuildDynamicCommands(
|
||
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
|
||
|
||
slog.Debug("plugin: stdio server registered",
|
||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||
"tools", len(tools), "commands", len(cmds))
|
||
|
||
return cmds
|
||
}
|
||
|
||
// newPipelineEngine creates and configures the pipeline engine with
|
||
// handlers for all five pipeline phases. The phases execute in order:
|
||
// Register → PreParse → PostParse → PreRequest → PostResponse.
|
||
//
|
||
// Phases are invoked at their respective integration points:
|
||
// - Register: during command tree construction (newMCPCommand)
|
||
// - PreParse: before Cobra parses raw argv (RunPreParse)
|
||
// - PostParse: after Cobra parsing, before validation (canonical RunE)
|
||
// - PreRequest: after validation, before JSON-RPC dispatch (canonical RunE)
|
||
// - PostResponse: after transport returns, before stdout (canonical RunE)
|
||
func newPipelineEngine() *pipeline.Engine {
|
||
engine := pipeline.NewEngine()
|
||
engine.RegisterAll(
|
||
// Register handler runs during command tree building.
|
||
handlers.RegisterHandler{},
|
||
|
||
// PreParse handlers run in order: alias → sticky → paramname.
|
||
// Alias normalises case first (--userId → --user-id), then
|
||
// sticky splits glued values (--limit100 → --limit 100), then
|
||
// paramname fixes near-miss typos (--limt → --limit).
|
||
handlers.AliasHandler{},
|
||
handlers.StickyHandler{},
|
||
handlers.ParamNameHandler{},
|
||
|
||
// PostParse handlers normalise structured values.
|
||
handlers.ParamValueHandler{},
|
||
|
||
// PreRequest handler inspects the validated payload before dispatch.
|
||
handlers.PreRequestHandler{},
|
||
|
||
// PostResponse handler processes the response before output.
|
||
handlers.PostResponseHandler{},
|
||
)
|
||
return engine
|
||
}
|