package plugin // manifest.go 实现插件清单(plugin.json)的解析和验证. // // 插件清单是一个 JSON 文件,位于插件目录的根目录下, // 定义了插件的基本信息,资源路径和依赖关系. // // 清单文件格式示例: // // { // "name": "my-plugin", // "description": "一个示例插件", // "version": "1.0.0", // "skills_path": "skills", // "hooks_path": "hooks.json", // "mcp_servers": { // "my-server": { // "transport": "stdio", // "command": "node", // "args": ["server.js"] // } // }, // "dependencies": ["other-plugin"] // } import ( "encoding/json" "fmt" "os" "path/filepath" "strings" ) // Manifest 是插件清单的结构体表示. // 对应插件目录下的 plugin.json 文件. type Manifest struct { // Name 插件名称(唯一标识符) Name string `json:"name"` // Description 插件描述 Description string `json:"description"` // Version 插件版本(语义化版本号) Version string `json:"version"` // SkillsPath 技能文件目录的相对路径(相对于插件目录) SkillsPath string `json:"skills_path"` // HooksPath hooks 配置文件的相对路径 HooksPath string `json:"hooks_path"` // MCPServers MCP 服务器配置. // // 升华改进(ELEVATED): JSON 字段名从 "mcp_servers" 改为 "mcpServers", // 与 Claude Code 插件格式对齐.这样同一个 plugin.json 可直接被 Claude Code 读取, // 无需手动转换,降低跨平台插件的维护成本. // 早期方案字段名 <"mcp_servers"> - 保留为向后兼容在下方 UnmarshalJSON 中处理. MCPServers map[string]MCPDef `json:"mcpServers"` // Dependencies 依赖的其他插件名称列表 Dependencies []string `json:"dependencies"` // ConfigSchema 插件配置字段定义(12.6 Plugin Config Schema). // // 升华改进(ELEVATED): 插件声明自己需要哪些配置项,引擎在加载时验证用户提供的 // 配置是否满足 Required 字段,并提供类型约束和描述. // 等价于 npm package.json 里的 peerDependencies + engines 约束-- // 消费层不必逐一阅读插件源码就能知道"这个插件需要我提供什么". // 替代方案:<文档约定(README 里写需要什么环境变量)> // - 否决:文档和代码分离,容易过期;机器无法校验,只能靠人工排查. ConfigSchema []ConfigFieldDef `json:"config_schema,omitempty"` // Tools 是 plugin 声明式注册的 shell tool 列表 (2026-04-15 新增). // // 每个 PluginToolDef 在加载时被 loadPluginTools 翻译成一个 pluginShellTool // 实例 (实现 tools.Tool interface), 存放在 Plugin.Tools 里, 供消费层 // (Host.GetAllTools) 注册到全局 tools.Registry. // // 这是 plugin 扩展 tool 的**第二条路径** (第一条是 MCPServers 配置). // 适用场景: 单 tool / 无状态 / shell 脚本就能搞定的简单扩展. // 详见 plugin_tool.go 的 "为什么同时需要 MCP server 和 declarative tool" 段落. Tools []PluginToolDef `json:"tools,omitempty"` // 注意: plugin 完整性校验**不在 manifest 内**. 原 scaffolding (commit 58c3ab5) // 曾设计 Security *SecurityMetadata 字段, 但 checksum 放在 manifest 里会产生 // 循环依赖 (plugin.json 的哈希依赖自己的 checksum 字段). 改用 sidecar // 文件 plugin.checksum (见 integrity.go) 后, 此字段已删除. // // 未来的真签名实现 (Ed25519 / Sshsig / PKCS#7) 也采用 sidecar 模型 // (例如 plugin.sig), 不回到 manifest 内嵌. 替代方案: - 否决, 需要 canonical JSON 序列化, // 复杂度高于 sidecar 模型. } // MCPDef 是 MCP 服务器定义. type MCPDef struct { // Transport 传输方式:stdio / sse / http / ws Transport string `json:"transport"` // Command stdio 模式下的可执行命令 Command string `json:"command,omitempty"` // Args 命令参数 Args []string `json:"args,omitempty"` // URL sse/http/ws 模式下的服务器 URL URL string `json:"url,omitempty"` // Env 环境变量 Env map[string]string `json:"env,omitempty"` } // HooksDef 是 hooks 配置文件的结构. type HooksDef struct { // Hooks 按 hook 类型分组的 hook 定义列表 Hooks map[string][]HookDef `json:"hooks"` } // HookDef 是单个 hook 的定义. type HookDef struct { // Command 要执行的 shell 命令 Command string `json:"command"` // Timeout 超时时间(秒),默认 30 Timeout int `json:"timeout"` } // ConfigFieldDef 是插件配置字段的定义(12.6 Plugin Config Schema). // // 字段 Type 使用 JSON Schema 风格的基础类型: // // "string" - 字符串(最常见:API Key,endpoint URL) // "number" - 数字(整数或浮点,如超时秒数,并发限制) // "boolean" - 布尔(true/false,如是否开启调试模式) // // 精妙之处(CLEVER): 只支持 3 种基础类型而非完整 JSON Schema-- // 插件配置通常只需要 string/number/boolean,完整的 $ref/$defs/oneOf 反而 // 增加了实现复杂度和消费层解析成本.若未来需要复杂类型(array/object), // 可在 Type 中增加,向后兼容(旧校验器遇到未知类型时 fallback 为 string). type ConfigFieldDef struct { // Key 配置项的键名(在 user config map 中对应的 key) Key string `json:"key"` // Type 值类型:"string" / "number" / "boolean" // 空字符串时默认视为 "string" Type string `json:"type"` // Required 是否为必填项. // true 时若用户配置 map 中不存在该 key,ValidatePluginConfig 返回错误. Required bool `json:"required"` // Default 默认值(字符串表示,非必填项可以有默认). // 校验时若 key 缺失且 Required==false,可用此值填充. // 历史包袱(LEGACY): 当前校验器不自动填充默认值(只报告缺失), // 调用方(Host.SetPluginConfig)负责应用默认值. // 未来如有需要,可在 ValidatePluginConfig 中补充自动填充逻辑. Default string `json:"default,omitempty"` // Description 人类可读的字段描述(用于 help 输出和 UI 展示) Description string `json:"description,omitempty"` // Secret 标记此字段是否为敏感值(如 API Key,密码). // true 时日志/错误消息中对该字段值进行脱敏(显示为 "***"). // // 升华改进(ELEVATED): Secret 标记让引擎主动保护敏感值-- // 插件作者只需声明"这是秘密",引擎层统一处理脱敏, // 无需每个插件自己实现 mask 逻辑. // 替代方案:<命名约定(key 包含 "secret"/"password" 的自动脱敏)> // - 否决:名称约定脆弱,容易遗漏("api_key" vs "apiKey" vs "key"). Secret bool `json:"secret"` } // MCPServerDef 是带有来源信息的 MCP 服务器定义. type MCPServerDef struct { // Name 服务器名称 Name string // PluginName 来源插件名称 PluginName string // MCPDef 服务器配置 MCPDef } // UnmarshalJSON 自定义反序列化,同时支持新字段名(mcpServers)和旧字段名(mcp_servers). // // 精妙之处(CLEVER): 用 type alias 的嵌套 struct 技巧,避免 UnmarshalJSON 无限递归: // manifestAlias 与 Manifest 有相同的字段(通过嵌入),但没有 UnmarshalJSON 方法, // 所以 json.Unmarshal 会走默认路径而不会再次调用此函数. // legacyFields 只声明旧字段名,两者都解码后再合并--新字段优先,旧字段兜底. func (m *Manifest) UnmarshalJSON(data []byte) error { type manifestAlias Manifest // 类型别名,无方法集,避免递归 type manifestCompat struct { manifestAlias MCPServersLegacy map[string]MCPDef `json:"mcp_servers"` // 旧格式兼容 } var mc manifestCompat if err := json.Unmarshal(data, &mc); err != nil { return err } *m = Manifest(mc.manifestAlias) // 旧字段兜底:如果新字段 mcpServers 为空,尝试读取旧字段 mcp_servers if len(m.MCPServers) == 0 && len(mc.MCPServersLegacy) > 0 { m.MCPServers = mc.MCPServersLegacy } return nil } // manifestFileName 是插件清单文件名. const manifestFileName = "plugin.json" // LoadManifest 从插件目录加载并解析清单文件. // // 参数: // - pluginDir: 插件目录的绝对路径 // // 返回解析后的清单和错误. // 如果 plugin.json 不存在或格式错误,返回相应的错误. func LoadManifest(pluginDir string) (*Manifest, error) { manifestPath := filepath.Join(pluginDir, manifestFileName) data, err := os.ReadFile(manifestPath) if err != nil { return nil, fmt.Errorf("read manifest %s: %w", manifestPath, err) } var manifest Manifest if err := json.Unmarshal(data, &manifest); err != nil { return nil, fmt.Errorf("parse manifest %s: %w", manifestPath, err) } // 验证必填字段 if err := validateManifest(&manifest); err != nil { return nil, fmt.Errorf("invalid manifest %s: %w", manifestPath, err) } // 解析相对路径(相对于插件目录) resolveManifestPaths(&manifest, pluginDir) return &manifest, nil } // validateManifest 验证清单的必填字段. func validateManifest(m *Manifest) error { if m.Name == "" { return fmt.Errorf("name is required") } // 验证名称格式:只允许字母,数字,连字符和下划线 for _, c := range m.Name { if !isValidNameChar(c) { return fmt.Errorf("invalid character '%c' in name (allowed: a-z, A-Z, 0-9, -, _)", c) } } // 验证版本格式(宽松检查,只要非空就行) // 不强制要求严格的语义化版本号 if m.Version == "" { m.Version = "0.0.0" // 默认版本 } // 验证 MCP 服务器配置 for name, mcpDef := range m.MCPServers { if mcpDef.Transport == "" { return fmt.Errorf("mcp_server %q: transport is required", name) } validTransports := map[string]bool{ "stdio": true, "sse": true, "http": true, "ws": true, } if !validTransports[mcpDef.Transport] { return fmt.Errorf("mcp_server %q: invalid transport %q (valid: stdio, sse, http, ws)", name, mcpDef.Transport) } if mcpDef.Transport == "stdio" && mcpDef.Command == "" { return fmt.Errorf("mcp_server %q: command is required for stdio transport", name) } if (mcpDef.Transport == "sse" || mcpDef.Transport == "http" || mcpDef.Transport == "ws") && mcpDef.URL == "" { return fmt.Errorf("mcp_server %q: url is required for %s transport", name, mcpDef.Transport) } } return nil } // resolveManifestPaths 将清单中的相对路径解析为绝对路径. func resolveManifestPaths(m *Manifest, pluginDir string) { // SkillsPath:默认为 "skills" if m.SkillsPath == "" { m.SkillsPath = "skills" } if !filepath.IsAbs(m.SkillsPath) { m.SkillsPath = filepath.Join(pluginDir, m.SkillsPath) } // HooksPath:默认为 "hooks.json" if m.HooksPath != "" && !filepath.IsAbs(m.HooksPath) { m.HooksPath = filepath.Join(pluginDir, m.HooksPath) } } // isValidNameChar 检查字符是否为合法的插件名称字符. func isValidNameChar(c rune) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' } // LoadHooks 从 hooks 文件加载 hook 定义. func LoadHooks(hooksPath string) (map[string][]HookDef, error) { if hooksPath == "" { return nil, nil } data, err := os.ReadFile(hooksPath) if err != nil { if os.IsNotExist(err) { return nil, nil // hooks 文件不存在不是错误 } return nil, fmt.Errorf("read hooks %s: %w", hooksPath, err) } var hooksDef HooksDef if err := json.Unmarshal(data, &hooksDef); err != nil { return nil, fmt.Errorf("parse hooks %s: %w", hooksPath, err) } // 验证 hook 定义 for hookType, hooks := range hooksDef.Hooks { for i, hook := range hooks { if hook.Command == "" { return nil, fmt.Errorf("hooks %s: hook[%d] of type %q: command is required", hooksPath, i, hookType) } if hook.Timeout <= 0 { hooksDef.Hooks[hookType][i].Timeout = 30 // 默认超时 30 秒 } } } return hooksDef.Hooks, nil } // FormatManifestInfo 将清单格式化为人类可读的信息字符串. func FormatManifestInfo(m *Manifest) string { var b strings.Builder fmt.Fprintf(&b, "Plugin: %s v%s\n", m.Name, m.Version) if m.Description != "" { fmt.Fprintf(&b, "Description: %s\n", m.Description) } if len(m.MCPServers) > 0 { fmt.Fprintf(&b, "MCP Servers: %d\n", len(m.MCPServers)) for name, def := range m.MCPServers { fmt.Fprintf(&b, " - %s (%s)\n", name, def.Transport) } } if len(m.Dependencies) > 0 { fmt.Fprintf(&b, "Dependencies: %s\n", strings.Join(m.Dependencies, ", ")) } return b.String() }