返回 DeepSeek-Reasonix
install_source.go
根目录 / internal / installsource / install_source.go
1 // Package installsource: install_source.go is the tool entrypoint. It
2 // defines the public Options/Execute surface, the JSON Schema, and the
3 // end-to-end pipeline that turns a request into a plan and (optionally)
4 // into a series of apply calls.
5 package installsource
6
7 import (
8 "context"
9 "crypto/sha256"
10 "encoding/hex"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "net/http"
15 "os"
16 "path/filepath"
17 "sort"
18 "strings"
19
20 "reasonix/internal/config"
21 "reasonix/internal/pluginpkg"
22 "reasonix/internal/skill"
23 "reasonix/internal/tool"
24 )
25
26 // MCPConnectResult is what the ConnectMCP callback returns. Disconnect is
27 // optional; when non-nil, the apply step will call it to undo a connect
28 // whose persistence (SaveTo) failed — closing the "ghost install" window.
29 type MCPConnectResult struct {
30 ToolCount int
31 Disconnect func() // optional; nil means rollback is not possible
32 }
33
34 // MCPConnector is the host-provided hook that turns a PluginEntry into a
35 // live MCP connection. The returned Disconnect, if any, is used by the
36 // install_source tool to roll back a failed persistence step.
37 type MCPConnector func(config.PluginEntry) (MCPConnectResult, error)
38
39 // ApprovalFunc is invoked between plan and apply when apply=true. Return
40 // nil to allow the install, or a non-nil error to refuse it. The action
41 // list reflects the exact set the apply step is about to perform; a host
42 // (e.g. the desktop TUI) can show it to the user and decide synchronously.
43 type ApprovalFunc func(actions []action) error
44
45 // OnDisconnectFunc tells the host to remove a server from the live session and
46 // drop the corresponding mcp__<name>__ tools from its Registry. It returns true
47 // when a live server was actually removed, letting replace/rollback restore the
48 // old connection only when there was one.
49 type OnDisconnectFunc func(serverName string) bool
50
51 // Options configure the install_source tool. ProjectRoot "" and HomeDir
52 // "" fall back to os.Getwd / os.UserHomeDir at construction time.
53 type Options struct {
54 ProjectRoot string
55 HomeDir string
56 HTTPClient *http.Client
57 ConnectMCP MCPConnector
58 OnDisconnect OnDisconnectFunc
59 Approval ApprovalFunc
60 }
61
62 type installSourceTool struct {
63 root string
64 home string
65 reasonixHome string
66 httpClient *http.Client
67 connectMCP MCPConnector
68 onDisconnect OnDisconnectFunc
69 approval ApprovalFunc
70 // preparePlugin overrides plugin source preparation in tests. nil uses
71 // preparePluginSource. Plan and apply both resolve the source through the
72 // same function, and git sources additionally report the resolved commit,
73 // so the capability set the approval covers is by construction the one
74 // that gets installed (apply pins the approved commit on divergence).
75 preparePlugin func(ctx context.Context, source, mode string) (root, commit string, cleanup func(), err error)
76 }
77
78 // NewTool returns a tool.Tool that callers register with the agent's
79 // Registry. The returned tool is safe to call from any goroutine; the
80 // underlying config/config.SaveTo paths do their own per-file locking.
81 func NewTool(opts Options) tool.Tool {
82 root := opts.ProjectRoot
83 if root == "" {
84 if wd, err := currentDir(); err == nil {
85 root = wd
86 }
87 }
88 if abs, err := filepath.Abs(root); err == nil {
89 root = abs
90 }
91 home := opts.HomeDir
92 if home == "" {
93 if h, err := userHomeDir(); err == nil {
94 home = h
95 }
96 }
97 reasonixHome := ""
98 if opts.HomeDir != "" {
99 reasonixHome = filepath.Join(home, ".reasonix")
100 } else if dir := config.ReasonixHomeDir(); dir != "" {
101 reasonixHome = dir
102 } else if home != "" {
103 reasonixHome = filepath.Join(home, ".reasonix")
104 }
105 client := opts.HTTPClient
106 if client == nil {
107 client = &http.Client{}
108 }
109 // install_source fetches untrusted URLs (SKILL.md, .mcp.json, GitHub
110 // manifests); guard the dial against SSRF the same way web_fetch does, so a
111 // prompt-injected source can't reach cloud metadata / internal services.
112 client = ssrfGuardClient(client)
113 return &installSourceTool{
114 root: root,
115 home: home,
116 reasonixHome: reasonixHome,
117 httpClient: client,
118 connectMCP: opts.ConnectMCP,
119 onDisconnect: opts.OnDisconnect,
120 approval: opts.Approval,
121 }
122 }
123
124 func (*installSourceTool) Name() string { return "install_source" }
125 func (*installSourceTool) ReadOnly() bool { return false }
126
127 func (*installSourceTool) Description() string {
128 return "Plan, install, or uninstall a Reasonix skill, MCP server, or plugin package from a URL, local file/folder, .mcp.json, executable, or package name. Two-phase: with apply=false (default) returns a deterministic plan with per-action risk level; with apply=true copies/registers skills, connects/persists MCP servers, or installs plugin packages after validation. op='uninstall' removes a previously installed skill, MCP server, or plugin package by name."
129 }
130
131 func (*installSourceTool) Schema() json.RawMessage {
132 return json.RawMessage(`{
133 "type":"object",
134 "properties":{
135 "op":{"type":"string","enum":["install","uninstall"],"description":"Whether to install (default) or uninstall."},
136 "source":{"type":"string","description":"URL, local file/folder path, .mcp.json path, or package name to install from. Ignored when op=uninstall (use name instead)."},
137 "kind":{"type":"string","enum":["auto","skill","mcp","plugin"],"description":"Capability kind. Defaults to auto."},
138 "apply":{"type":"boolean","description":"false (default) only returns an install plan; true performs the planned writes/connects. Ignored for op=uninstall."},
139 "scope":{"type":"string","enum":["project","global"],"description":"Where to persist config or copy skills. MCP installs default to global so every project can use them; project-root .mcp.json imports default to project; skills default to project when a workspace exists, otherwise global."},
140 "mode":{"type":"string","enum":["auto","copy","link","register"],"description":"Skill install mode. auto registers multi-skill roots and copies single skills into the canonical <skill-name>/SKILL.md layout; copy copies skill files/folders; link creates symlinks; register adds a skill root to [skills].paths."},
141 "name":{"type":"string","description":"Optional override for the installed MCP server or single skill name. Required for op=uninstall when removing by name."},
142 "transport":{"type":"string","enum":["auto","stdio","http","sse"],"description":"MCP transport override. URL sources default to http unless --sse-like; package sources default to stdio."},
143 "command":{"type":"string","description":"Optional stdio MCP command override for package/local executable installs."},
144 "args":{"type":"array","items":{"type":"string"},"description":"Optional stdio MCP args override."},
145 "env":{"type":"object","additionalProperties":{"type":"string"},"description":"Environment variables for stdio MCP servers."},
146 "headers":{"type":"object","additionalProperties":{"type":"string"},"description":"HTTP headers for remote MCP servers. Prefer ${VAR} placeholders for secrets."},
147 "tier":{"type":"string","enum":["background","eager"],"description":"Persisted MCP startup tier. Defaults to background."},
148 "replace":{"type":"boolean","description":"Allow replacing an existing MCP config entry with the same name. Skills still refuse to overwrite existing files."},
149 "strict":{"type":"boolean","description":"Skill install strictness. true (default) requires name+description frontmatter; false copies the file as-is (use only for files you trust)."},
150 "planId":{"type":"string","description":"Optional. Echoed from a previous planned response to confirm the host is approving the same plan."}
151 },
152 "required":[]
153 }`)
154 }
155
156 // Execute parses args, plans, and (if apply=true and Approval allows)
157 // performs the writes. JSON output is always returned on success even when
158 // the plan is empty, so the model can read structured `next` hints.
159 func (t *installSourceTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
160 var req request
161 if err := json.Unmarshal(raw, &req); err != nil {
162 return "", fmt.Errorf("install_source: invalid args: %w", err)
163 }
164 req.Source = strings.TrimSpace(req.Source)
165 if req.Op == "" {
166 req.Op = "install"
167 }
168 if req.Op != "install" && req.Op != "uninstall" {
169 return "", fmt.Errorf("install_source: op %q is not supported (want install|uninstall)", req.Op)
170 }
171 if req.Op == "install" && req.Source == "" {
172 return "", errors.New("install_source requires a non-empty source")
173 }
174 if req.Op == "uninstall" && strings.TrimSpace(req.Name) == "" {
175 return "", errors.New("install_source: op=uninstall requires a non-empty name")
176 }
177 req.Kind = normalizeKind(req.Kind)
178 req.Scope, req.scopeExplicit = t.normalizeScope(req.Scope)
179 req.Mode = normalizeMode(req.Mode)
180 req.Transport = normalizeTransport(req.Transport)
181 if norm, ok := normalizeTier(req.Tier); ok {
182 req.Tier = norm
183 }
184
185 if req.Op == "uninstall" {
186 return t.executeUninstall(req), nil
187 }
188
189 actions, warnings, err := t.plan(ctx, req)
190 if err != nil {
191 if errors.Is(err, ErrNoCompatibleCapabilities) {
192 return marshalJSON(response{
193 OK: false, Status: "blocked", Op: req.Op, Applied: false,
194 Source: req.Source, Kind: "plugin", Scope: req.Scope, Mode: req.Mode,
195 Warnings: warnings, Error: err.Error(),
196 Next: "Choose a plugin that exports a supported skill, command, agent, hook, or MCP server.",
197 }), nil
198 }
199 return "", err
200 }
201 // Marketplace planning may keep one temporary clone alive so apply can
202 // reuse the exact approved snapshot. Clean it on every exit path, including
203 // plan-ID mismatch or host approval denial before executeApply runs.
204 defer cleanupActionResources(actions)
205 planID := computePlanID(req, actions)
206 if len(actions) == 0 {
207 out := response{
208 OK: false,
209 Status: "blocked",
210 Op: req.Op,
211 Applied: false,
212 Source: req.Source,
213 Kind: "",
214 Scope: req.Scope,
215 Mode: req.Mode,
216 PlanID: planID,
217 Warnings: warnings,
218 Next: "No installable Reasonix skill, MCP server, or plugin package was detected. Ask the user for a direct SKILL.md, skill root, .mcp.json, plugin manifest, MCP endpoint, or package name.",
219 }
220 return marshalJSON(out), nil
221 }
222
223 if !req.Apply {
224 for i := range actions {
225 actions[i].Status = "planned"
226 }
227 scope := commonActionScope(actions)
228 out := response{
229 OK: true,
230 Status: "planned",
231 Op: req.Op,
232 Applied: false,
233 Source: req.Source,
234 Kind: summarizeKind(actions),
235 Kinds: kindCounts(actions),
236 Scope: scope,
237 Mode: req.Mode,
238 PlanID: planID,
239 Actions: publicActions(actions),
240 Warnings: warnings,
241 Next: "Review the plan (especially each action's riskLevel). Call install_source again with apply=true and the same planId to install.",
242 }
243 return marshalJSON(out), nil
244 }
245
246 if req.PlanID != "" && req.PlanID != planID {
247 return "", newErr(ErrApprovalDenied, "planId mismatch (got %s, expected %s); re-plan and re-approve", req.PlanID, planID)
248 }
249 if t.approval != nil {
250 if err := t.approval(publicActions(actions)); err != nil {
251 return marshalJSON(response{
252 OK: false,
253 Status: "denied",
254 Op: req.Op,
255 Applied: false,
256 Source: req.Source,
257 Kind: summarizeKind(actions),
258 Kinds: kindCounts(actions),
259 Scope: req.Scope,
260 Mode: req.Mode,
261 PlanID: planID,
262 Actions: publicActions(actions),
263 Warnings: append(warnings, "host approval was denied: "+err.Error()),
264 Next: "Ask the user to confirm, or run with a less risky plan (e.g. lower scope, fewer actions).",
265 }), nil
266 }
267 }
268
269 return t.executeApply(ctx, req, actions, warnings, planID), nil
270 }
271
272 // executeApply runs the apply phase. The first failed action short-circuits
273 // the rest only when a single failure implies the plan is unusable; for
274 // MCP installs in particular, partial completion is reported honestly.
275 func (t *installSourceTool) executeApply(ctx context.Context, req request, actions []action, warnings []string, planID string) string {
276 ok := true
277 anySucceeded := false
278 for i := range actions {
279 if err := t.apply(ctx, req, &actions[i]); err != nil {
280 ok = false
281 actions[i].Status = "failed"
282 actions[i].Error = err.Error()
283 if actions[i].Next == "" {
284 actions[i].Next = nextForError(err)
285 }
286 continue
287 }
288 actions[i].Status = "done"
289 anySucceeded = true
290 warnings = append(warnings, actions[i].Warnings...)
291 }
292 status := "done"
293 next := "Installed and verified."
294 if !ok {
295 if anySucceeded {
296 status = "partial"
297 next = "Some actions succeeded; the failed ones are listed in actions[].status=failed. Re-plan those and retry."
298 } else {
299 status = "failed"
300 next = "No action succeeded. Fix the first failed action[] entry and retry install_source with apply=true."
301 }
302 }
303 return marshalJSON(response{
304 OK: ok,
305 Status: status,
306 Op: req.Op,
307 Applied: true,
308 Source: req.Source,
309 Kind: summarizeKind(actions),
310 Kinds: kindCounts(actions),
311 Scope: commonActionScope(actions),
312 Mode: req.Mode,
313 PlanID: planID,
314 Actions: publicActions(actions),
315 Warnings: warnings,
316 Next: next,
317 })
318 }
319
320 func cleanupActionResources(actions []action) {
321 for i := range actions {
322 if actions[i].cleanup != nil {
323 actions[i].cleanup()
324 actions[i].cleanup = nil
325 }
326 }
327 }
328
329 // executeUninstall handles op=uninstall. It locates the named entry in the
330 // active config (skills via the on-disk layout, MCP via cfg.Plugins) and
331 // asks the host to disconnect. We do not consult the approval hook for
332 // uninstall: the user already named the entry, and removal is the inverse
333 // of the install they authorized.
334 func (t *installSourceTool) executeUninstall(req request) string {
335 actions := []action{}
336 scopes := t.uninstallSearchScopes(req)
337 for _, scope := range scopes {
338 actions = t.uninstallActionsForScope(req.Name, scope)
339 if len(actions) > 0 {
340 break
341 }
342 }
343
344 scope := commonActionScope(actions)
345 if len(actions) == 0 {
346 if len(scopes) == 1 {
347 scope = scopes[0]
348 } else {
349 scope = strings.Join(scopes, "/")
350 }
351 return marshalJSON(response{
352 OK: false,
353 Status: "blocked",
354 Op: req.Op,
355 Applied: false,
356 Source: req.Source,
357 Name: req.Name,
358 Scope: scope,
359 Next: "No installed skill or MCP server matched that name in the chosen scope.",
360 })
361 }
362
363 // Uninstall is destructive but symmetric with a previously approved
364 // install, so we apply directly. Each action is independent.
365 ok := true
366 anySucceeded := false
367 for i := range actions {
368 if err := t.apply(context.Background(), req, &actions[i]); err != nil {
369 ok = false
370 actions[i].Status = "failed"
371 actions[i].Error = err.Error()
372 actions[i].Next = "Inspect the error, then retry op=uninstall."
373 continue
374 }
375 actions[i].Status = "done"
376 anySucceeded = true
377 }
378 status := "done"
379 if !ok {
380 status = "partial"
381 if !anySucceeded {
382 status = "failed"
383 }
384 }
385 return marshalJSON(response{
386 OK: ok,
387 Status: status,
388 Op: req.Op,
389 Applied: true,
390 Source: req.Source,
391 Name: req.Name,
392 Kind: summarizeKind(actions),
393 Kinds: kindCounts(actions),
394 Scope: scope,
395 Actions: publicActions(actions),
396 Next: "Removed.",
397 })
398 }
399
400 func (t *installSourceTool) uninstallSearchScopes(req request) []string {
401 if req.scopeExplicit && req.Scope != "" {
402 return []string{req.Scope}
403 }
404 scopes := []string{}
405 if strings.TrimSpace(t.root) != "" {
406 scopes = append(scopes, "project")
407 }
408 return append(scopes, "global")
409 }
410
411 func (t *installSourceTool) uninstallActionsForScope(name, scope string) []action {
412 var actions []action
413 cfgPath := t.configPath(scope)
414 cfg := config.LoadForEdit(cfgPath)
415
416 // Skills: try the flat file, then the directory layout, in the chosen
417 // scope. We don't require a kind — "name" disambiguates.
418 if path, ok := t.resolveSkillPath(name, scope); ok {
419 actions = append(actions, action{
420 Kind: "skill",
421 Action: "remove_skill",
422 Name: name,
423 Target: path,
424 Scope: scope,
425 ConfigPath: cfgPath,
426 RiskLevel: RiskLow,
427 })
428 } else if rootAction, ok := t.resolveRegisteredSkillRoot(name, scope, cfgPath, cfg); ok {
429 actions = append(actions, rootAction)
430 }
431
432 // MCP: scan the chosen config for the named plugin.
433 for _, p := range cfg.Plugins {
434 if p.Name == name {
435 actions = append(actions, action{
436 Kind: "mcp",
437 Action: "remove_mcp_server",
438 Name: p.Name,
439 Target: p.URL,
440 Scope: scope,
441 Transport: pluginTransport(p),
442 ConfigPath: cfgPath,
443 RiskLevel: RiskMedium,
444 RiskReasons: []string{
445 "disconnects a running server and drops its tools from the active session",
446 },
447 })
448 break
449 }
450 }
451 if scope == "global" || scope == "" {
452 if st, err := pluginpkg.LoadState(t.reasonixHome); err == nil {
453 for _, p := range st.Plugins {
454 if p.Name != name {
455 continue
456 }
457 root := pluginpkg.ResolveRoot(t.reasonixHome, p.Root)
458 actions = append(actions, action{
459 Kind: "plugin",
460 Action: "remove_plugin_package",
461 Name: p.Name,
462 Target: root,
463 Scope: "global",
464 ConfigPath: pluginpkg.StatePath(t.reasonixHome),
465 ManifestKind: p.ManifestKind,
466 Version: p.Version,
467 RiskLevel: RiskMedium,
468 RiskReasons: []string{
469 "removes a plugin package and disables its skills, hooks, and MCP servers",
470 },
471 })
472 break
473 }
474 }
475 }
476 return actions
477 }
478
479 // resolveSkillPath finds the on-disk location of a previously installed
480 // skill of the given name in the chosen scope. The bool reports whether
481 // the path is a real install (Lstat succeeded). Both flat (<name>.md) and
482 // directory (<name>/) layouts are checked.
483 func (t *installSourceTool) resolveSkillPath(name, scope string) (string, bool) {
484 if !config.IsValidSkillName(name) {
485 return "", false
486 }
487 var root string
488 if scope == "global" {
489 if t.reasonixHome == "" {
490 return "", false
491 }
492 root = filepath.Join(t.reasonixHome, skill.SkillsDirname)
493 } else {
494 root = filepath.Join(t.root, ".reasonix", skill.SkillsDirname)
495 }
496 flat := filepath.Join(root, name+".md")
497 if _, err := lstat(flat); err == nil {
498 return flat, true
499 }
500 dir := filepath.Join(root, name)
501 if _, err := lstat(filepath.Join(dir, skill.SkillFile)); err == nil {
502 return dir, true
503 }
504 return "", false
505 }
506
507 func (t *installSourceTool) resolveRegisteredSkillRoot(name, scope, cfgPath string, cfg *config.Config) (action, bool) {
508 if !config.IsValidSkillName(name) {
509 return action{}, false
510 }
511 for _, rawPath := range cfg.Skills.Paths {
512 path := t.resolvePath(config.ExpandVars(rawPath))
513 cands, err := scanSkillRoot(path, false)
514 if err != nil {
515 continue
516 }
517 var names []string
518 found := false
519 for _, cand := range cands {
520 names = append(names, cand.Name)
521 if cand.Name == name {
522 found = true
523 }
524 }
525 if !found {
526 continue
527 }
528 sort.Strings(names)
529 return action{
530 Kind: "skill",
531 Action: "remove_skill_root",
532 Name: name,
533 Target: rawPath,
534 Scope: scope,
535 ConfigPath: cfgPath,
536 Skills: names,
537 SkillCount: len(names),
538 RiskLevel: RiskMedium,
539 RiskReasons: []string{
540 "removes a registered skill root from [skills].paths and may hide every skill in that folder",
541 },
542 }, true
543 }
544 return action{}, false
545 }
546
547 func (t *installSourceTool) configPath(scope string) string {
548 if scope == "global" {
549 if p := config.UserConfigPath(); p != "" {
550 return p
551 }
552 }
553 return filepath.Join(t.root, "reasonix.toml")
554 }
555
556 func (t *installSourceTool) normalizeScope(scope string) (string, bool) {
557 switch strings.ToLower(strings.TrimSpace(scope)) {
558 case "project":
559 return "project", true
560 case "global":
561 return "global", true
562 default:
563 return "", false
564 }
565 }
566
567 func (t *installSourceTool) installScope(req request, kind, source string) string {
568 if req.scopeExplicit && req.Scope != "" {
569 return req.Scope
570 }
571 if kind == "mcp" {
572 if t.isProjectMCPJSONSource(source) {
573 return "project"
574 }
575 return "global"
576 }
577 if strings.TrimSpace(t.root) != "" {
578 return "project"
579 }
580 return "global"
581 }
582
583 func (t *installSourceTool) isProjectMCPJSONSource(source string) bool {
584 if isURL(source) || !strings.EqualFold(filepath.Base(source), ".mcp.json") {
585 return false
586 }
587 root := strings.TrimSpace(t.root)
588 if root == "" {
589 return false
590 }
591 sourceAbs, sourceErr := filepath.Abs(source)
592 rootAbs, rootErr := filepath.Abs(root)
593 if sourceErr != nil || rootErr != nil {
594 return false
595 }
596 rel, err := filepath.Rel(filepath.Clean(rootAbs), filepath.Clean(sourceAbs))
597 if err != nil {
598 return false
599 }
600 return rel == ".mcp.json" || (!strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..")
601 }
602
603 func commonActionScope(actions []action) string {
604 if len(actions) == 0 {
605 return ""
606 }
607 scope := actions[0].Scope
608 for _, action := range actions[1:] {
609 if action.Scope != scope {
610 return "mixed"
611 }
612 }
613 return scope
614 }
615
616 func (t *installSourceTool) resolvePath(p string) string {
617 p = strings.TrimSpace(p)
618 if strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) {
619 p = filepath.Join(t.home, p[2:])
620 } else if p == "~" {
621 p = t.home
622 }
623 if !filepath.IsAbs(p) {
624 p = filepath.Join(t.root, p)
625 }
626 if abs, err := filepath.Abs(p); err == nil {
627 p = abs
628 }
629 return filepath.Clean(p)
630 }
631
632 // computePlanID hashes the request plus the full public action set so a later
633 // apply call with the same planId can be verified to be approving exactly the
634 // same plan. It intentionally excludes Apply and PlanID; everything that changes
635 // what will be written/connected must live either in req's planning fields or in
636 // the action DTO.
637 func computePlanID(req request, actions []action) string {
638 public := publicActions(actions)
639 sort.Slice(public, func(i, j int) bool {
640 return actionPlanKey(public[i]) < actionPlanKey(public[j])
641 })
642 payload := struct {
643 Op string `json:"op"`
644 Source string `json:"source"`
645 Kind string `json:"kind"`
646 Scope string `json:"scope"`
647 Mode string `json:"mode"`
648 Name string `json:"name"`
649 Transport string `json:"transport"`
650 Command string `json:"command"`
651 Args []string `json:"args,omitempty"`
652 Env map[string]string `json:"env,omitempty"`
653 Headers map[string]string `json:"headers,omitempty"`
654 Tier string `json:"tier"`
655 Replace bool `json:"replace"`
656 Strict bool `json:"strict"`
657 Actions []action `json:"actions"`
658 }{
659 Op: req.Op,
660 Source: req.Source,
661 Kind: req.Kind,
662 Scope: commonActionScope(actions),
663 Mode: req.Mode,
664 Name: req.Name,
665 Transport: req.Transport,
666 Command: req.Command,
667 Args: req.Args,
668 Env: req.Env,
669 Headers: req.Headers,
670 Tier: req.Tier,
671 Replace: req.Replace,
672 Strict: req.strict(),
673 Actions: public,
674 }
675 body, _ := json.Marshal(payload)
676 h := sha256.New()
677 h.Write(body)
678 return "sha256:" + hex.EncodeToString(h.Sum(nil)[:16])
679 }
680
681 // kindCounts tallies the per-kind action count for the response. Skill
682 // skills and MCP servers in the same plan get separate counts so the
683 // caller can summarize accurately.
684 func kindCounts(actions []action) kindTally {
685 var out kindTally
686 for _, a := range actions {
687 switch a.Kind {
688 case "skill":
689 out.Skill++
690 case "mcp":
691 out.MCP++
692 case "plugin":
693 out.Plugin++
694 }
695 }
696 return out
697 }
698
699 // nextForError maps a sentinel error to a short remediation hint. Callers
700 // use it as the default `next` value when a plan step fails.
701 func nextForError(err error) string {
702 switch {
703 case errors.Is(err, ErrAuthRequired):
704 return "Authentication is required. Add the needed token as an environment variable or header placeholder, then retry."
705 case errors.Is(err, ErrBinaryMissing):
706 return "Install the missing local runtime or use an absolute command path, then retry."
707 case errors.Is(err, ErrAlreadyExists):
708 return "Choose another name, remove the existing entry, or retry MCP installs with replace=true."
709 case errors.Is(err, ErrUnsafeLinkTarget):
710 return "The link target escapes the project/home root. Pick a source path inside the workspace or home directory."
711 case errors.Is(err, ErrApprovalDenied):
712 return "Host denied the install. Re-run without apply=true to revise the plan, or ask the user to confirm."
713 case errors.Is(err, ErrManifestMissing):
714 return "No installable manifest was found at the source. Provide a direct SKILL.md, .mcp.json, executable, or package name."
715 case errors.Is(err, ErrInvalidManifest):
716 return "The manifest was found but did not validate. Check required fields (command/url/tier)."
717 case errors.Is(err, ErrSourceUnreadable):
718 return "The source could not be read. Check the URL/path and try again."
719 default:
720 return "Inspect the error, fix the source or environment, then retry."
721 }
722 }
723
724 // currentDir / userHomeDir / lstat are tiny wrappers that exist so tests
725 // can stub them; the wrappers today just call the stdlib versions.
726 var (
727 currentDir = defaultCurrentDir
728 userHomeDir = defaultUserHomeDir
729 lstat = defaultLstat
730 )
731
732 func defaultCurrentDir() (string, error) { return os.Getwd() }
733 func defaultUserHomeDir() (string, error) { return os.UserHomeDir() }
734 func defaultLstat(path string) (os.FileInfo, error) { return os.Lstat(path) }
735
735 lines GO