| 1 | package installsource |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | |
| 6 | "reasonix/internal/config" |
| 7 | "reasonix/internal/pluginpkg" |
| 8 | ) |
| 9 | |
| 10 | // request mirrors the public schema. Fields stay exported because Execute |
| 11 | // unmarshals directly into this struct; the struct comment is the source of |
| 12 | // truth for the field semantics surfaced to the model. |
| 13 | type request struct { |
| 14 | Op string `json:"op"` // "install" (default) | "uninstall" |
| 15 | Source string `json:"source"` // required for install; ignored for uninstall |
| 16 | Kind string `json:"kind"` // auto|skill|mcp|plugin (install only) |
| 17 | Apply bool `json:"apply"` // install only: actually write/connect |
| 18 | Scope string `json:"scope"` // project|global |
| 19 | Mode string `json:"mode"` // auto|copy|link|register (skill install) |
| 20 | Name string `json:"name"` // override the discovered name |
| 21 | Transport string `json:"transport"` // auto|stdio|http|sse |
| 22 | Command string `json:"command"` |
| 23 | Args []string `json:"args"` |
| 24 | Env map[string]string `json:"env"` |
| 25 | Headers map[string]string `json:"headers"` |
| 26 | Tier string `json:"tier"` |
| 27 | Replace bool `json:"replace"` // overwrite existing entries |
| 28 | Strict *bool `json:"strict"` // nil -> true; require skill frontmatter |
| 29 | // PlanID is echoed back on a confirm-apply call so the host can refuse |
| 30 | // to apply a plan that does not match the one it approved. |
| 31 | PlanID string `json:"planId"` |
| 32 | |
| 33 | scopeExplicit bool |
| 34 | } |
| 35 | |
| 36 | // response is the JSON shape returned to the model. Status is one of |
| 37 | // "planned" (apply=false, no writes), "done" (all actions succeeded), |
| 38 | // "partial" (some actions succeeded), "failed" (no actions succeeded), or |
| 39 | // "blocked" (no plan could be produced). The `Kind` field is preserved for |
| 40 | // back-compat with callers that only handle a single capability; `Kinds` |
| 41 | // reports per-kind counts. |
| 42 | type response struct { |
| 43 | OK bool `json:"ok"` |
| 44 | Status string `json:"status"` |
| 45 | Op string `json:"op"` |
| 46 | Applied bool `json:"applied"` |
| 47 | Source string `json:"source"` |
| 48 | Name string `json:"name,omitempty"` |
| 49 | Kind string `json:"kind"` // legacy single kind, "" when no plan |
| 50 | Kinds kindTally `json:"kinds,omitempty"` // counts per kind |
| 51 | Scope string `json:"scope"` |
| 52 | Mode string `json:"mode"` |
| 53 | PlanID string `json:"planId,omitempty"` |
| 54 | Actions []action `json:"actions"` |
| 55 | Warnings []string `json:"warnings,omitempty"` |
| 56 | Error string `json:"error,omitempty"` |
| 57 | Next string `json:"next,omitempty"` |
| 58 | } |
| 59 | |
| 60 | // kindTally reports per-kind counts. It is a struct (not a map) so the JSON |
| 61 | // shape stays stable: missing fields read as zero, and we can add new kinds |
| 62 | // without breaking old clients. |
| 63 | type kindTally struct { |
| 64 | Skill int `json:"skill"` |
| 65 | MCP int `json:"mcp"` |
| 66 | Plugin int `json:"plugin"` |
| 67 | } |
| 68 | |
| 69 | // action is the per-install-step DTO. The Kind/Action pair drives the apply |
| 70 | // dispatcher; RiskLevel/RiskReasons help the calling skill decide whether to |
| 71 | // ask the user before apply=true. |
| 72 | type action struct { |
| 73 | Kind string `json:"kind"` // "skill" | "mcp" | "plugin" |
| 74 | Action string `json:"action"` // copy_skill|link_skill|register_skill_root|install_mcp_server|remove_skill|remove_skill_root|remove_mcp_server |
| 75 | Status string `json:"status"` // planned|done|failed |
| 76 | RiskLevel RiskLevel `json:"riskLevel"` // low|medium|high |
| 77 | RiskReasons []string `json:"riskReasons,omitempty"` |
| 78 | Name string `json:"name,omitempty"` |
| 79 | Source string `json:"source,omitempty"` |
| 80 | Target string `json:"target,omitempty"` |
| 81 | ConfigPath string `json:"configPath,omitempty"` |
| 82 | Scope string `json:"scope,omitempty"` |
| 83 | Mode string `json:"mode,omitempty"` |
| 84 | Transport string `json:"transport,omitempty"` |
| 85 | URL string `json:"url,omitempty"` |
| 86 | Command string `json:"command,omitempty"` |
| 87 | Args []string `json:"args,omitempty"` |
| 88 | Env map[string]string `json:"env,omitempty"` |
| 89 | Headers map[string]string `json:"headers,omitempty"` |
| 90 | Skills []string `json:"skills,omitempty"` |
| 91 | SkillCount int `json:"skillCount,omitempty"` |
| 92 | Agents []string `json:"agents,omitempty"` |
| 93 | AgentCount int `json:"agentCount,omitempty"` |
| 94 | Commands []string `json:"commands,omitempty"` |
| 95 | CommandCount int `json:"commandCount,omitempty"` |
| 96 | Commit string `json:"commit,omitempty"` // resolved git snapshot the plan describes; apply pins to it |
| 97 | Layout string `json:"layout,omitempty"` // canonical_dir|flat_compat|registered_root |
| 98 | InstallRoot string `json:"installRoot,omitempty"` // skills root or registered custom root |
| 99 | CanonicalPath string `json:"canonicalPath,omitempty"` // <skill-name>/SKILL.md when applicable |
| 100 | Discoverable bool `json:"discoverable,omitempty"` // Store.Read can load it after apply/register |
| 101 | Indexed bool `json:"indexed,omitempty"` // Store.List includes it for the skills index |
| 102 | ToolCount int `json:"toolCount,omitempty"` |
| 103 | Compatibility string `json:"compatibility,omitempty"` |
| 104 | MappedCapabilities []string `json:"mappedCapabilities,omitempty"` |
| 105 | SkippedCapabilities []pluginpkg.CompatibilityIssue `json:"skippedCapabilities,omitempty"` |
| 106 | HookCount int `json:"hookCount,omitempty"` |
| 107 | ManifestKind string `json:"manifestKind,omitempty"` |
| 108 | Version string `json:"version,omitempty"` |
| 109 | PromptCount int `json:"promptCount,omitempty"` |
| 110 | ThemeCount int `json:"themeCount,omitempty"` |
| 111 | // Runtime carries a plugin package's declared runtime process so |
| 112 | // frontends can render the full-trust implications before approval. |
| 113 | // Purely additive: older consumers ignore it. |
| 114 | Runtime *RuntimePlanInfo `json:"runtime,omitempty"` |
| 115 | Warnings []string `json:"warnings,omitempty"` |
| 116 | Error string `json:"error,omitempty"` |
| 117 | Next string `json:"next,omitempty"` |
| 118 | |
| 119 | // Internal state used by apply. Stripped by publicActions before |
| 120 | // serializing to JSON. |
| 121 | entry config.PluginEntry |
| 122 | skill skillCandidate |
| 123 | disconnect func() // optional MCP rollback; nil when not connected |
| 124 | // preparedRoot lets a multi-plugin marketplace apply reuse the exact clone |
| 125 | // that produced its approved plan instead of cloning the same repository |
| 126 | // once per plugin. cleanup is attached to one action and runs after all |
| 127 | // actions finish. |
| 128 | preparedRoot string |
| 129 | cleanup func() |
| 130 | } |
| 131 | |
| 132 | // RuntimePlanInfo describes a plugin package's declared runtime process in |
| 133 | // the install plan. A runtime executes inside Reasonix with the user's full |
| 134 | // trust — it can read the session and environment, bypass permissions, and |
| 135 | // operate the machine directly — so FullTrust is always true when present |
| 136 | // and the plan action also carries RiskHigh with a FULL TRUST reason. |
| 137 | type RuntimePlanInfo struct { |
| 138 | Command string `json:"command"` |
| 139 | Args []string `json:"args,omitempty"` |
| 140 | Intercepts []string `json:"intercepts,omitempty"` |
| 141 | Replaces []string `json:"replaces,omitempty"` |
| 142 | Capabilities []string `json:"capabilities,omitempty"` |
| 143 | FullTrust bool `json:"fullTrust"` |
| 144 | } |
| 145 | |
| 146 | func actionPlanKey(a action) string { |
| 147 | return a.Kind + ":" + a.Name + ":" + a.Action + ":" + a.Source + ":" + a.Target + ":" + a.ConfigPath |
| 148 | } |
| 149 | |
| 150 | // skillCandidate is a parsed skill file/directory ready to install. The |
| 151 | // caller decides whether to copy, link, or register the source path. |
| 152 | type skillCandidate struct { |
| 153 | Name string |
| 154 | Description string |
| 155 | SourcePath string |
| 156 | RootPath string |
| 157 | IsDir bool |
| 158 | Content string |
| 159 | } |
| 160 | |
| 161 | // summarizeKind returns a one-word kind label for callers that only want the |
| 162 | // dominant capability. "mixed" is returned when the plan contains both |
| 163 | // skills and MCP servers; counts live in response.Kinds. |
| 164 | func summarizeKind(actions []action) string { |
| 165 | seen := map[string]bool{} |
| 166 | for _, a := range actions { |
| 167 | seen[a.Kind] = true |
| 168 | } |
| 169 | if len(seen) > 1 { |
| 170 | return "mixed" |
| 171 | } |
| 172 | for k := range seen { |
| 173 | return k |
| 174 | } |
| 175 | return "" |
| 176 | } |
| 177 | |
| 178 | // publicActions strips unexported state from each action so the JSON |
| 179 | // response does not leak internal config types. |
| 180 | func publicActions(in []action) []action { |
| 181 | out := make([]action, len(in)) |
| 182 | for i := range in { |
| 183 | out[i] = in[i] |
| 184 | out[i].entry = config.PluginEntry{} |
| 185 | out[i].skill = skillCandidate{} |
| 186 | out[i].preparedRoot = "" |
| 187 | out[i].cleanup = nil |
| 188 | } |
| 189 | return out |
| 190 | } |
| 191 | |
| 192 | func marshalJSON(v any) string { |
| 193 | b, _ := json.Marshal(v) |
| 194 | return string(b) |
| 195 | } |
| 196 |