返回 DeepSeek-Reasonix
manifest_v2.go
根目录 / internal / pluginpkg / manifest_v2.go
1 package pluginpkg
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "reasonix/internal/extensioncontract"
12 "reasonix/internal/fileutil"
13 fileencoding "reasonix/internal/fileutil/encoding"
14 )
15
16 // ManifestAPIVersionV2 is the only native plugin manifest apiVersion Reasonix
17 // accepts for install, doctor, and boot. v1 and legacy (no apiVersion) native
18 // manifests are rejected by the parser. Legacy pre-extension manifests can be
19 // upgraded explicitly, or automatically when they live in Reasonix's managed
20 // plugin directory where an atomic backup is safe.
21 const ManifestAPIVersionV2 = "reasonix.io/plugin/v2"
22
23 // CapabilityRef is the wire form of a capability in a v2 manifest.
24 type CapabilityRef struct {
25 Namespace string `json:"namespace"`
26 Kind string `json:"kind"`
27 ID string `json:"id"`
28 Version string `json:"version,omitempty"`
29 VersionRange string `json:"versionRange,omitempty"`
30 SchemaHash string `json:"schemaHash,omitempty"`
31 Optional bool `json:"optional,omitempty"`
32 }
33
34 // ToCapability converts a provides entry.
35 func (c CapabilityRef) ToCapability() extensioncontract.Capability {
36 return extensioncontract.Capability{
37 Key: extensioncontract.CapabilityKey{
38 Namespace: strings.TrimSpace(c.Namespace),
39 Kind: strings.TrimSpace(c.Kind),
40 ID: strings.TrimSpace(c.ID),
41 },
42 Version: strings.TrimSpace(c.Version),
43 SchemaHash: strings.TrimSpace(c.SchemaHash),
44 }
45 }
46
47 // ToRequirement converts a requires entry.
48 func (c CapabilityRef) ToRequirement() extensioncontract.Requirement {
49 return extensioncontract.Requirement{
50 Capability: extensioncontract.Capability{
51 Key: extensioncontract.CapabilityKey{
52 Namespace: strings.TrimSpace(c.Namespace),
53 Kind: strings.TrimSpace(c.Kind),
54 ID: strings.TrimSpace(c.ID),
55 },
56 Version: strings.TrimSpace(c.Version),
57 SchemaHash: strings.TrimSpace(c.SchemaHash),
58 },
59 VersionRange: strings.TrimSpace(c.VersionRange),
60 Optional: c.Optional,
61 }
62 }
63
64 // checkAPIVersionV2 gates the v2 parser. Only the exact frozen string
65 // reasonix.io/plugin/v2 is accepted (no v2.0 / v2.1 aliases).
66 func checkAPIVersionV2(v string) error {
67 if v == ManifestAPIVersionV2 {
68 return nil
69 }
70 return fmt.Errorf("%s: unsupported apiVersion %q: want exact %s", NativeManifest, v, ManifestAPIVersionV2)
71 }
72
73 // rejectNonV2Native explains why a non-v2 native manifest is refused.
74 func rejectNonV2Native(apiVersion string) error {
75 if apiVersion == "" {
76 return fmt.Errorf("%s: missing apiVersion; native manifests must declare %s", NativeManifest, ManifestAPIVersionV2)
77 }
78 return checkAPIVersionV2(apiVersion)
79 }
80
81 type v2Root struct {
82 APIVersion string `json:"apiVersion"`
83 Name string `json:"name"`
84 Version string `json:"version"`
85 Description string `json:"description"`
86 Homepage string `json:"homepage"`
87 Repository string `json:"repository"`
88 Requires json.RawMessage `json:"requires"`
89 Provides json.RawMessage `json:"provides"`
90 Contributes json.RawMessage `json:"contributes"`
91 Runtime json.RawMessage `json:"runtime"`
92 // Resource path fields (same shapes as earlier native manifests).
93 Skills json.RawMessage `json:"skills"`
94 Commands json.RawMessage `json:"commands"`
95 Hooks map[string][]json.RawMessage `json:"hooks"`
96 MCPServers map[string]json.RawMessage `json:"mcpServers"`
97 }
98
99 func parseNativeV2(b []byte, root, apiVersion string) (Package, []string, error) {
100 if err := checkAPIVersionV2(apiVersion); err != nil {
101 return Package{}, nil, err
102 }
103 var raw v2Root
104 if err := strictDecode(b, &raw, NativeManifest); err != nil {
105 return Package{}, nil, err
106 }
107 var contrib v1Contributes
108 if len(raw.Contributes) > 0 && string(raw.Contributes) != "null" {
109 if err := strictDecode(raw.Contributes, &contrib, "contributes"); err != nil {
110 return Package{}, nil, err
111 }
112 }
113
114 legacySkills, err := parseV1PathList(raw.Skills, "skills")
115 if err != nil {
116 return Package{}, nil, err
117 }
118 legacyCommands, err := parseV1PathList(raw.Commands, "commands")
119 if err != nil {
120 return Package{}, nil, err
121 }
122 contribSkills, err := parseV1PathList(contrib.Skills, "contributes.skills")
123 if err != nil {
124 return Package{}, nil, err
125 }
126 contribAgents, err := parseV1PathList(contrib.Agents, "contributes.agents")
127 if err != nil {
128 return Package{}, nil, err
129 }
130 contribCommands, err := parseV1PathList(contrib.Commands, "contributes.commands")
131 if err != nil {
132 return Package{}, nil, err
133 }
134 contribPrompts, err := parseV1PathList(contrib.Prompts, "contributes.prompts")
135 if err != nil {
136 return Package{}, nil, err
137 }
138 contribThemes, err := parseV1PathList(contrib.Themes, "contributes.themes")
139 if err != nil {
140 return Package{}, nil, err
141 }
142 legacyHooks, err := parseV1HookMap(raw.Hooks, "hooks")
143 if err != nil {
144 return Package{}, nil, err
145 }
146 contribHooks, err := parseV1HookMap(contrib.Hooks, "contributes.hooks")
147 if err != nil {
148 return Package{}, nil, err
149 }
150 legacyMCP, err := parseV1MCPServerMap(raw.MCPServers, "mcpServers")
151 if err != nil {
152 return Package{}, nil, err
153 }
154 contribMCP, err := parseV1MCPServerMap(contrib.MCPServers, "contributes.mcpServers")
155 if err != nil {
156 return Package{}, nil, err
157 }
158 hooks, err := mergeV1Hooks(legacyHooks, contribHooks)
159 if err != nil {
160 return Package{}, nil, err
161 }
162 mcpServers, err := mergeV1MCPServers(legacyMCP, contribMCP)
163 if err != nil {
164 return Package{}, nil, err
165 }
166 runtime, err := parseV1Runtime(raw.Runtime)
167 if err != nil {
168 return Package{}, nil, err
169 }
170 requires, err := parseCapabilityRefs(raw.Requires, "requires", true)
171 if err != nil {
172 return Package{}, nil, err
173 }
174 provides, err := parseCapabilityRefs(raw.Provides, "provides", false)
175 if err != nil {
176 return Package{}, nil, err
177 }
178
179 manifest := Manifest{
180 APIVersion: ManifestAPIVersionV2,
181 Name: strings.TrimSpace(raw.Name),
182 Version: strings.TrimSpace(raw.Version),
183 Description: strings.TrimSpace(raw.Description),
184 Homepage: strings.TrimSpace(raw.Homepage),
185 Repository: strings.TrimSpace(raw.Repository),
186 Skills: unionPathLists(legacySkills, contribSkills),
187 Commands: unionPathLists(legacyCommands, contribCommands),
188 Agents: contribAgents,
189 Prompts: contribPrompts,
190 Themes: contribThemes,
191 Hooks: hooks,
192 MCPServers: mcpServers,
193 Runtime: runtime,
194 Requires: requires,
195 Provides: provides,
196 }
197 if err := validateManifest(root, &manifest); err != nil {
198 return Package{}, nil, err
199 }
200 if err := validateV2Capabilities(&manifest); err != nil {
201 return Package{}, nil, err
202 }
203 var warnings []string
204 pathWarnings, err := validateV1Paths(root, &manifest)
205 warnings = append(warnings, pathWarnings...)
206 if err != nil {
207 return Package{}, warnings, err
208 }
209 pkg := Package{Root: root, ManifestKind: "reasonix", Manifest: manifest}
210 pkg.Compatibility = compatibilityFor(pkg, nil)
211 return pkg, warnings, nil
212 }
213
214 func parseCapabilityRefs(raw json.RawMessage, path string, asRequirement bool) ([]CapabilityRef, error) {
215 if len(raw) == 0 || string(raw) == "null" {
216 return nil, nil
217 }
218 var items []json.RawMessage
219 if err := json.Unmarshal(raw, &items); err != nil {
220 return nil, fmt.Errorf("%s must be an array", path)
221 }
222 out := make([]CapabilityRef, 0, len(items))
223 for i, item := range items {
224 var ref CapabilityRef
225 if err := strictDecode(item, &ref, fmt.Sprintf("%s[%d]", path, i)); err != nil {
226 return nil, err
227 }
228 ref.Namespace = strings.TrimSpace(ref.Namespace)
229 ref.Kind = strings.TrimSpace(ref.Kind)
230 ref.ID = strings.TrimSpace(ref.ID)
231 ref.Version = strings.TrimSpace(ref.Version)
232 ref.VersionRange = strings.TrimSpace(ref.VersionRange)
233 ref.SchemaHash = strings.TrimSpace(ref.SchemaHash)
234 if asRequirement {
235 if err := ref.ToRequirement().Validate(); err != nil {
236 return nil, fmt.Errorf("%s[%d]: %w", path, i, err)
237 }
238 } else {
239 if err := ref.ToCapability().Validate(); err != nil {
240 return nil, fmt.Errorf("%s[%d]: %w", path, i, err)
241 }
242 }
243 out = append(out, ref)
244 }
245 return out, nil
246 }
247
248 func validateV2Capabilities(m *Manifest) error {
249 seen := map[string]bool{}
250 for i, p := range m.Provides {
251 key := p.ToCapability().Key.String()
252 if seen[key] {
253 return fmt.Errorf("provides[%d]: duplicate capability %s", i, key)
254 }
255 seen[key] = true
256 }
257 return nil
258 }
259
260 // ComponentDescriptorFields projects a package into kernel component fields.
261 func (p Package) Requires() []extensioncontract.Requirement {
262 out := make([]extensioncontract.Requirement, 0, len(p.Manifest.Requires))
263 for _, r := range p.Manifest.Requires {
264 out = append(out, r.ToRequirement())
265 }
266 return out
267 }
268
269 // ProvidesCapabilities returns the manifest capability ceiling.
270 func (p Package) ProvidesCapabilities() []extensioncontract.Capability {
271 out := make([]extensioncontract.Capability, 0, len(p.Manifest.Provides))
272 for _, c := range p.Manifest.Provides {
273 out = append(out, c.ToCapability())
274 }
275 return out
276 }
277
278 // MigrateManifestToV2 converts a legacy native package into a v2
279 // manifest document. Dependencies that cannot be inferred are returned as
280 // errors rather than invented.
281 func MigrateManifestToV2(pkg Package) ([]byte, error) {
282 if pkg.ManifestKind != "reasonix" && pkg.ManifestKind != "" {
283 return nil, fmt.Errorf("migrate: only native reasonix manifests can be migrated (got %s)", pkg.ManifestKind)
284 }
285 if apiVersion := strings.TrimSpace(pkg.Manifest.APIVersion); apiVersion != "" {
286 return nil, fmt.Errorf("migrate: only pre-extension manifests without apiVersion can be migrated (got %s)", apiVersion)
287 }
288 name := strings.TrimSpace(pkg.Manifest.Name)
289 if name == "" {
290 return nil, errors.New("migrate: manifest name is required")
291 }
292 version := strings.TrimSpace(pkg.Manifest.Version)
293 if version == "" {
294 version = "1.0.0"
295 }
296 doc := map[string]any{
297 "apiVersion": ManifestAPIVersionV2,
298 "name": name,
299 "version": version,
300 }
301 if d := strings.TrimSpace(pkg.Manifest.Description); d != "" {
302 doc["description"] = d
303 }
304 if h := strings.TrimSpace(pkg.Manifest.Homepage); h != "" {
305 doc["homepage"] = h
306 }
307 if r := strings.TrimSpace(pkg.Manifest.Repository); r != "" {
308 doc["repository"] = r
309 }
310
311 contrib := map[string]any{}
312 if len(pkg.Manifest.Skills) > 0 {
313 contrib["skills"] = pkg.Manifest.Skills
314 }
315 if len(pkg.Manifest.Agents) > 0 {
316 contrib["agents"] = pkg.Manifest.Agents
317 }
318 if len(pkg.Manifest.Commands) > 0 {
319 contrib["commands"] = pkg.Manifest.Commands
320 }
321 if len(pkg.Manifest.Prompts) > 0 {
322 contrib["prompts"] = pkg.Manifest.Prompts
323 }
324 if len(pkg.Manifest.Themes) > 0 {
325 contrib["themes"] = pkg.Manifest.Themes
326 }
327 if len(pkg.Manifest.Hooks) > 0 {
328 contrib["hooks"] = pkg.Manifest.Hooks
329 }
330 if len(pkg.Manifest.MCPServers) > 0 {
331 contrib["mcpServers"] = pkg.Manifest.MCPServers
332 }
333 if len(contrib) > 0 {
334 doc["contributes"] = contrib
335 }
336 if rt := pkg.Manifest.Runtime; rt != nil {
337 doc["runtime"] = rt
338 // Infer provides from runtime capabilities when possible; otherwise leave empty.
339 var provides []CapabilityRef
340 for _, capName := range rt.Capabilities {
341 switch capName {
342 case "providers":
343 return nil, fmt.Errorf("migrate: runtime capability %q requires explicit provides entries (cannot infer schemaHash)", capName)
344 case "ui":
345 return nil, fmt.Errorf("migrate: runtime capability %q requires explicit provides entries (cannot infer schemaHash)", capName)
346 case "interceptors", "strategies":
347 provides = append(provides, CapabilityRef{
348 Namespace: "plugin/" + name,
349 Kind: capName,
350 ID: "default",
351 Version: "1.0.0",
352 })
353 }
354 }
355 if len(provides) > 0 {
356 doc["provides"] = provides
357 }
358 }
359 if len(pkg.Manifest.Requires) > 0 {
360 doc["requires"] = pkg.Manifest.Requires
361 }
362 if len(pkg.Manifest.Provides) > 0 {
363 doc["provides"] = pkg.Manifest.Provides
364 }
365 return json.MarshalIndent(doc, "", " ")
366 }
367
368 // WriteMigratedManifestV2 writes a v2 manifest, keeping a .bak backup of the
369 // previous reasonix-plugin.json when present.
370 func WriteMigratedManifestV2(root string, data []byte) error {
371 root = filepath.Clean(root)
372 path := filepath.Join(root, NativeManifest)
373 if b, err := os.ReadFile(path); err == nil {
374 bak := path + ".bak"
375 if err := fileutil.AtomicWriteFile(bak, b, 0o644); err != nil {
376 return fmt.Errorf("migrate: backup: %w", err)
377 }
378 }
379 if !strings.HasSuffix(string(data), "\n") {
380 data = append(data, '\n')
381 }
382 return fileutil.AtomicWriteFile(path, data, 0o644)
383 }
384
385 // ParseNativeForMigrate accepts legacy native manifests without apiVersion for
386 // explicit migration only. Normal ParseDir rejects them; v1 is unsupported.
387 func ParseNativeForMigrate(root string) (Package, []string, error) {
388 root = filepath.Clean(root)
389 path := filepath.Join(root, NativeManifest)
390 b, err := os.ReadFile(path)
391 if err != nil {
392 return Package{}, nil, err
393 }
394 apiVersion, err := sniffManifestAPIVersion(b)
395 if err != nil {
396 return Package{}, nil, err
397 }
398 if apiVersion == "" {
399 return parseNativeLegacy(b, root)
400 }
401 if apiVersion == ManifestAPIVersionV2 {
402 return Package{}, nil, fmt.Errorf("%s: already uses %s; migration only supports pre-extension manifests without apiVersion", NativeManifest, ManifestAPIVersionV2)
403 }
404 return Package{}, nil, rejectNonV2Native(apiVersion)
405 }
406
407 func parseNative(path, root string) (Package, []string, error) {
408 b, err := fileencoding.ReadFileUTF8(path)
409 if err != nil {
410 return Package{}, nil, err
411 }
412 apiVersion, err := sniffManifestAPIVersion(b)
413 if err != nil {
414 return Package{}, nil, err
415 }
416 if apiVersion != ManifestAPIVersionV2 {
417 return Package{}, nil, rejectNonV2Native(apiVersion)
418 }
419 return parseNativeV2(b, root, apiVersion)
420 }
421
421 lines GO