| 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 |