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