返回 DeepSeek-Reasonix
plan.go
1 package installsource
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/url"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12 "unicode"
13
14 "reasonix/internal/skill"
15 )
16
17 var githubAPIBaseURL = "https://api.github.com"
18
19 // plan turns a request into a list of actions plus a warnings slice. It
20 // does not touch the disk; the apply phase is responsible for side effects.
21 func (t *installSourceTool) plan(ctx context.Context, req request) ([]action, []string, error) {
22 if strings.HasPrefix(req.Source, "git:github.com/") {
23 req.Source = "https://github.com/" + strings.TrimPrefix(req.Source, "git:github.com/")
24 }
25 if isURL(req.Source) {
26 return t.planURL(ctx, req)
27 }
28 path := t.resolvePath(req.Source)
29 if info, err := os.Stat(path); err == nil {
30 return t.planLocal(req, path, info)
31 }
32 if req.Kind == "auto" || req.Kind == "mcp" {
33 if looksLikePackage(req.Source) {
34 return []action{t.packageMCPAction(req)}, nil, nil
35 }
36 }
37 return nil, nil, newErr(ErrSourceUnreadable, "source %q is not a readable local path, URL, or supported package name", req.Source)
38 }
39
40 func (t *installSourceTool) planURL(ctx context.Context, req request) ([]action, []string, error) {
41 rawURL := rawGitHubBlobURL(req.Source)
42 if req.Kind == "auto" || req.Kind == "plugin" {
43 actions, warnings, err := t.planGitHubPluginPackage(ctx, req)
44 if err == nil && len(actions) > 0 {
45 return actions, warnings, nil
46 }
47 if req.Kind == "plugin" {
48 return nil, warnings, err
49 }
50 }
51 if req.Kind == "mcp" && !looksLikeMarkdownURL(rawURL) && !looksLikeMCPJSONURL(rawURL) {
52 return []action{t.remoteMCPAction(req, rawURL)}, nil, nil
53 }
54 if looksLikeMarkdownURL(rawURL) || looksLikeMCPJSONURL(rawURL) || rawURL != req.Source {
55 actions, warnings, err := t.planDownloadedURL(ctx, req, rawURL)
56 if err == nil && len(actions) > 0 {
57 return actions, warnings, nil
58 }
59 if req.Kind != "auto" {
60 return nil, warnings, err
61 }
62 }
63 if gitActions, warnings := t.tryGitHubRepo(ctx, req); len(gitActions) > 0 {
64 return gitActions, warnings, nil
65 }
66 if req.Kind == "skill" {
67 return nil, nil, newErr(ErrUnsupportedKind, "URL %q is not a direct markdown skill file or GitHub SKILL.md", req.Source)
68 }
69 if req.Kind == "auto" && !looksLikeRemoteMCPEndpoint(req.Source) {
70 return nil, nil, newErr(ErrUnsupportedKind, "URL %q is not a direct MCP endpoint or skill manifest; provide a raw SKILL.md, .mcp.json, or use kind='mcp' for a remote MCP endpoint", req.Source)
71 }
72 return []action{t.remoteMCPAction(req, req.Source)}, nil, nil
73 }
74
75 func (t *installSourceTool) planDownloadedURL(ctx context.Context, req request, sourceURL string) ([]action, []string, error) {
76 body, err := t.fetchText(ctx, sourceURL)
77 if err != nil {
78 return nil, nil, err
79 }
80 if req.Kind == "auto" || req.Kind == "mcp" {
81 entries, warnings, err := parseMCPJSON([]byte(body))
82 if err == nil && len(entries) > 0 {
83 actions := make([]action, 0, len(entries))
84 for _, e := range entries {
85 actions = append(actions, t.mcpEntryAction(req, e, sourceURL))
86 }
87 return actions, warnings, nil
88 }
89 }
90 if req.Kind == "auto" || req.Kind == "skill" {
91 name := strings.TrimSpace(req.Name)
92 if name == "" {
93 name = nameFromURL(sourceURL)
94 }
95 cand, err := parseSkillContent(body, name, sourceURL, req.strict())
96 if err == nil {
97 return []action{t.skillAction(req, cand, "copy")}, nil, nil
98 }
99 return nil, nil, err
100 }
101 return nil, nil, newErr(ErrUnsupportedKind, "downloaded URL did not contain a requested %s install source", req.Kind)
102 }
103
104 func (t *installSourceTool) tryGitHubRepo(ctx context.Context, req request) ([]action, []string) {
105 if req.Kind != "auto" && req.Kind != "skill" && req.Kind != "mcp" {
106 return nil, nil
107 }
108 src, ok := parseGitHubRepoSource(req.Source)
109 if !ok {
110 return nil, nil
111 }
112 var warnings []string
113 for _, branch := range src.branches() {
114 if req.Kind == "auto" || req.Kind == "mcp" {
115 cand := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", src.Owner, src.Repo, branch, joinURLPath(src.Path, ".mcp.json"))
116 actions, _, err := t.planDownloadedURL(ctx, req, cand)
117 if err == nil && len(actions) > 0 {
118 return actions, warnings
119 }
120 if err != nil {
121 warnings = append(warnings, fmt.Sprintf("%s: %s", cand, err.Error()))
122 }
123 }
124 if req.Kind == "auto" || req.Kind == "skill" {
125 actions, skillWarnings, err := t.planGitHubSkillRepo(ctx, req, src, branch)
126 warnings = append(warnings, skillWarnings...)
127 if err == nil && len(actions) > 0 {
128 return actions, warnings
129 }
130 if err != nil {
131 warnings = append(warnings, fmt.Sprintf("github repo %s/%s@%s: %s", src.Owner, src.Repo, branch, err.Error()))
132 }
133 }
134 }
135 return nil, warnings
136 }
137
138 type githubRepoSource struct {
139 Owner string
140 Repo string
141 Branch string
142 Path string
143 }
144
145 func (s githubRepoSource) branches() []string {
146 if s.Branch != "" {
147 return []string{s.Branch}
148 }
149 return []string{"main", "master"}
150 }
151
152 func parseGitHubRepoSource(source string) (githubRepoSource, bool) {
153 u, err := url.Parse(source)
154 if err != nil || (u.Scheme != "http" && u.Scheme != "https") ||
155 !strings.EqualFold(u.Hostname(), "github.com") || u.User != nil ||
156 u.Port() != "" || u.RawQuery != "" || u.Fragment != "" ||
157 hasUnsafeGitHubSourceCharacters(source) {
158 return githubRepoSource{}, false
159 }
160 escapedPath := u.EscapedPath()
161 if !strings.HasPrefix(escapedPath, "/") || strings.Contains(strings.TrimPrefix(escapedPath, "/"), "//") {
162 return githubRepoSource{}, false
163 }
164 escapedPath = strings.TrimPrefix(escapedPath, "/")
165 escapedPath = strings.TrimSuffix(escapedPath, "/")
166 if escapedPath == "" {
167 return githubRepoSource{}, false
168 }
169 escapedParts := strings.Split(escapedPath, "/")
170 parts := make([]string, 0, len(escapedParts))
171 for _, escapedPart := range escapedParts {
172 part, err := url.PathUnescape(escapedPart)
173 if err != nil || part == "" || part == "." || part == ".." ||
174 strings.ContainsAny(part, "/\\") || hasUnsafeGitHubSourceCharacters(part) {
175 return githubRepoSource{}, false
176 }
177 parts = append(parts, part)
178 }
179 if len(parts) < 2 || !packageNameRe.MatchString(parts[0]) {
180 return githubRepoSource{}, false
181 }
182 repo := strings.TrimSuffix(parts[1], ".git")
183 if !packageNameRe.MatchString(repo) {
184 return githubRepoSource{}, false
185 }
186 out := githubRepoSource{Owner: parts[0], Repo: repo}
187 if len(parts) == 2 {
188 return out, true
189 }
190 if len(parts) < 4 || parts[2] != "tree" {
191 return githubRepoSource{}, false
192 }
193 out.Branch = parts[3]
194 if len(parts) > 4 {
195 out.Path = strings.Join(parts[4:], "/")
196 }
197 return out, true
198 }
199
200 func hasUnsafeGitHubSourceCharacters(value string) bool {
201 for _, r := range value {
202 if unicode.IsSpace(r) || unicode.IsControl(r) {
203 return true
204 }
205 }
206 return false
207 }
208
209 type githubContentEntry struct {
210 Name string `json:"name"`
211 Path string `json:"path"`
212 Type string `json:"type"`
213 DownloadURL string `json:"download_url"`
214 }
215
216 func (t *installSourceTool) planGitHubSkillRepo(ctx context.Context, req request, src githubRepoSource, branch string) ([]action, []string, error) {
217 cands, warnings, err := t.scanGitHubSkills(ctx, req, src, branch)
218 if err != nil {
219 return nil, warnings, err
220 }
221 if len(cands) == 0 {
222 return nil, warnings, newErr(ErrManifestMissing, "no SKILL.md or <name>.md skills found under GitHub repo path %s", firstNonEmpty(src.Path, "."))
223 }
224 actions := make([]action, 0, len(cands))
225 for _, cand := range cands {
226 actions = append(actions, t.skillAction(req, cand, "copy"))
227 }
228 sort.Slice(actions, func(i, j int) bool { return actions[i].Name < actions[j].Name })
229 return actions, warnings, nil
230 }
231
232 func (t *installSourceTool) scanGitHubSkills(ctx context.Context, req request, src githubRepoSource, branch string) ([]skillCandidate, []string, error) {
233 var out []skillCandidate
234 var warnings []string
235 var walk func(path string, depth int) error
236 walk = func(path string, depth int) error {
237 if depth > maxSkillScanDepth {
238 return nil
239 }
240 entries, err := t.fetchGitHubContents(ctx, src, branch, path)
241 if err != nil {
242 return err
243 }
244 sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path })
245 for _, entry := range entries {
246 if len(out) >= maxSkillScanCount {
247 return newErr(ErrInvalidManifest, "too many skills under GitHub repo; limit is %d", maxSkillScanCount)
248 }
249 switch entry.Type {
250 case "dir":
251 if skipSkillRepoDir(entry.Name) {
252 continue
253 }
254 if err := walk(entry.Path, depth+1); err != nil {
255 return err
256 }
257 case "file":
258 cand, ok, warning := t.githubSkillCandidate(ctx, req, entry, src.Repo)
259 if warning != "" {
260 warnings = append(warnings, warning)
261 }
262 if ok {
263 out = append(out, cand)
264 }
265 }
266 }
267 return nil
268 }
269 err := walk(src.Path, 0)
270 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
271 return out, warnings, err
272 }
273
274 func (t *installSourceTool) githubSkillCandidate(ctx context.Context, req request, entry githubContentEntry, repoName string) (skillCandidate, bool, string) {
275 if entry.DownloadURL == "" {
276 return skillCandidate{}, false, ""
277 }
278 base := filepath.Base(entry.Path)
279 if !strings.EqualFold(filepath.Ext(base), ".md") {
280 return skillCandidate{}, false, ""
281 }
282 fallback := strings.TrimSuffix(base, filepath.Ext(base))
283 if strings.EqualFold(base, skill.SkillFile) {
284 parent := filepath.Base(filepath.Dir(entry.Path))
285 if parent == "." || parent == "" {
286 parent = repoName
287 }
288 fallback = parent
289 }
290 body, err := t.fetchText(ctx, entry.DownloadURL)
291 if err != nil {
292 return skillCandidate{}, false, fmt.Sprintf("%s: %s", entry.DownloadURL, err.Error())
293 }
294 cand, err := parseSkillContent(body, fallback, entry.DownloadURL, req.strict())
295 if err != nil {
296 if strings.EqualFold(base, skill.SkillFile) {
297 return skillCandidate{}, false, err.Error()
298 }
299 return skillCandidate{}, false, ""
300 }
301 cand.SourcePath = entry.DownloadURL
302 cand.Content = body
303 return cand, true, ""
304 }
305
306 func (t *installSourceTool) fetchGitHubContents(ctx context.Context, src githubRepoSource, branch, path string) ([]githubContentEntry, error) {
307 apiURL, err := url.Parse(strings.TrimRight(githubAPIBaseURL, "/"))
308 if err != nil {
309 return nil, err
310 }
311 apiURL.Path = "/" + joinURLPath(apiURL.Path, "repos", src.Owner, src.Repo, "contents", path)
312 q := apiURL.Query()
313 q.Set("ref", branch)
314 apiURL.RawQuery = q.Encode()
315 body, err := t.fetchText(ctx, apiURL.String())
316 if err != nil {
317 return nil, err
318 }
319 var entries []githubContentEntry
320 if err := json.Unmarshal([]byte(body), &entries); err == nil {
321 return entries, nil
322 }
323 var single githubContentEntry
324 if err := json.Unmarshal([]byte(body), &single); err != nil {
325 return nil, newErr(ErrInvalidManifest, "%s: invalid GitHub contents response: %v", apiURL.String(), err)
326 }
327 return []githubContentEntry{single}, nil
328 }
329
330 func skipSkillRepoDir(name string) bool {
331 switch strings.ToLower(strings.TrimSpace(name)) {
332 case "", ".git", ".github", "node_modules", "references", "scripts", "assets":
333 return true
334 default:
335 return false
336 }
337 }
338
339 func joinURLPath(parts ...string) string {
340 var cleaned []string
341 for _, part := range parts {
342 part = strings.Trim(part, "/")
343 if part != "" {
344 cleaned = append(cleaned, part)
345 }
346 }
347 return strings.Join(cleaned, "/")
348 }
349
350 func (t *installSourceTool) planLocal(req request, path string, info os.FileInfo) ([]action, []string, error) {
351 var actions []action
352 var warnings []string
353 if info.IsDir() && (req.Kind == "auto" || req.Kind == "plugin") {
354 pluginAction, pluginWarnings, err := t.localPluginPackageAction(req, path)
355 if err == nil {
356 return []action{pluginAction}, pluginWarnings, nil
357 }
358 if req.Kind == "plugin" {
359 return nil, pluginWarnings, err
360 }
361 warnings = append(warnings, err.Error())
362 }
363 if req.Kind == "auto" || req.Kind == "mcp" {
364 mcpPath := path
365 if info.IsDir() {
366 mcpPath = filepath.Join(path, ".mcp.json")
367 }
368 if filepath.Base(mcpPath) == ".mcp.json" {
369 entries, mcpWarnings, err := readMCPJSON(mcpPath)
370 if err == nil && len(entries) > 0 {
371 for _, e := range entries {
372 actions = append(actions, t.mcpEntryAction(req, e, mcpPath))
373 }
374 warnings = append(warnings, mcpWarnings...)
375 } else if req.Kind == "mcp" {
376 return nil, nil, err
377 }
378 }
379 if !info.IsDir() && isExecutable(path, info) && filepath.Base(path) != ".mcp.json" {
380 actions = append(actions, t.localExecutableMCPAction(req, path))
381 }
382 }
383 if req.Kind == "auto" || req.Kind == "skill" {
384 skillActions, err := t.localSkillActions(req, path, info)
385 if err != nil && req.Kind == "skill" {
386 return nil, nil, err
387 }
388 if err != nil && req.Kind == "auto" {
389 warnings = append(warnings, err.Error())
390 }
391 actions = append(actions, skillActions...)
392 }
393 if len(actions) == 0 {
394 return nil, warnings, newErr(ErrManifestMissing, "no installable MCP server, skill, or plugin package found at %s", path)
395 }
396 sort.SliceStable(actions, func(i, j int) bool {
397 if actions[i].Kind != actions[j].Kind {
398 return actions[i].Kind < actions[j].Kind
399 }
400 return actions[i].Name < actions[j].Name
401 })
402 return actions, warnings, nil
403 }
404
405 func (t *installSourceTool) localSkillActions(req request, path string, info os.FileInfo) ([]action, error) {
406 strict := req.strict()
407 if !info.IsDir() {
408 if !strings.EqualFold(filepath.Ext(path), ".md") {
409 return nil, newErr(ErrUnsupportedKind, "not a markdown skill file: %s", path)
410 }
411 fallback := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
412 if strings.EqualFold(filepath.Base(path), skill.SkillFile) {
413 fallback = filepath.Base(filepath.Dir(path))
414 }
415 cand, err := readSkillFile(path, fallback, strict)
416 if err != nil {
417 return nil, err
418 }
419 if strings.EqualFold(filepath.Base(path), skill.SkillFile) {
420 cand.IsDir = true
421 cand.SourcePath = filepath.Dir(path)
422 cand.RootPath = filepath.Dir(filepath.Dir(path))
423 } else {
424 cand.RootPath = filepath.Dir(path)
425 }
426 if req.Name != "" {
427 cand.Name = req.Name
428 }
429 if req.Mode == "register" {
430 root := cand.RootPath
431 if root == "" {
432 root = filepath.Dir(path)
433 }
434 return []action{t.skillRootAction(req, root, []string{cand.Name})}, nil
435 }
436 return []action{t.skillAction(req, cand, modeForSingleSkill(req.Mode))}, nil
437 }
438 if st, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil && st.Mode().IsRegular() {
439 cand, err := readSkillFile(filepath.Join(path, skill.SkillFile), filepath.Base(path), strict)
440 if err != nil {
441 return nil, err
442 }
443 cand.IsDir = true
444 cand.SourcePath = path
445 cand.RootPath = filepath.Dir(path)
446 if req.Name != "" {
447 cand.Name = req.Name
448 }
449 if req.Mode == "register" {
450 return []action{t.skillRootAction(req, filepath.Dir(path), []string{cand.Name})}, nil
451 }
452 return []action{t.skillAction(req, cand, modeForSingleSkill(req.Mode))}, nil
453 }
454 cands, err := scanSkillRoot(path, strict)
455 if err != nil {
456 return nil, err
457 }
458 if len(cands) == 0 {
459 return nil, newErr(ErrManifestMissing, "no SKILL.md or <name>.md skills found under %s", path)
460 }
461 mode := req.Mode
462 if mode == "auto" {
463 mode = "register"
464 }
465 if mode == "register" {
466 byRoot := map[string][]string{}
467 for _, cand := range cands {
468 root := cand.RootPath
469 if root == "" {
470 root = path
471 }
472 byRoot[root] = append(byRoot[root], cand.Name)
473 }
474 roots := make([]string, 0, len(byRoot))
475 for root := range byRoot {
476 roots = append(roots, root)
477 }
478 sort.Strings(roots)
479 actions := make([]action, 0, len(roots))
480 for _, root := range roots {
481 rootNames := byRoot[root]
482 sort.Strings(rootNames)
483 actions = append(actions, t.skillRootAction(req, root, rootNames))
484 }
485 return actions, nil
486 }
487 actions := make([]action, 0, len(cands))
488 for _, cand := range cands {
489 actions = append(actions, t.skillAction(req, cand, mode))
490 }
491 return actions, nil
492 }
493
494 // strict returns the effective strict setting, defaulting to true when the
495 // caller did not set the field.
496 func (r request) strict() bool {
497 if r.Strict == nil {
498 return true
499 }
500 return *r.Strict
501 }
502
502 lines GO