| 1 | package installsource |
| 2 | |
| 3 | import ( |
| 4 | "net/url" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "regexp" |
| 8 | "runtime" |
| 9 | "strings" |
| 10 | |
| 11 | "reasonix/internal/config" |
| 12 | ) |
| 13 | |
| 14 | // packageNameRe matches valid npm package-name segments. Pinned by [a-z0-9._-] |
| 15 | // — exactly what npm allows. The leading character may be a digit (scoped |
| 16 | // packages like @5/test are rare but valid). |
| 17 | var packageNameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) |
| 18 | |
| 19 | func isURL(s string) bool { |
| 20 | u, err := url.Parse(s) |
| 21 | return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" |
| 22 | } |
| 23 | |
| 24 | func looksLikeMarkdownURL(s string) bool { |
| 25 | u, err := url.Parse(s) |
| 26 | if err != nil { |
| 27 | return false |
| 28 | } |
| 29 | return strings.EqualFold(filepath.Ext(u.Path), ".md") |
| 30 | } |
| 31 | |
| 32 | func looksLikeMCPJSONURL(s string) bool { |
| 33 | u, err := url.Parse(s) |
| 34 | if err != nil { |
| 35 | return false |
| 36 | } |
| 37 | return strings.EqualFold(filepath.Base(u.Path), ".mcp.json") |
| 38 | } |
| 39 | |
| 40 | // looksLikeRemoteMCPEndpoint is a loose heuristic: any URL with "mcp" or |
| 41 | // "sse" in its path, or a host that clearly advertises mcp, is treated as a |
| 42 | // remote MCP endpoint when no manifest could be downloaded. Used as a fallback |
| 43 | // for the auto-resolver; users can always set kind="mcp" to bypass the guess. |
| 44 | func looksLikeRemoteMCPEndpoint(s string) bool { |
| 45 | u, err := url.Parse(s) |
| 46 | if err != nil { |
| 47 | return false |
| 48 | } |
| 49 | host := strings.ToLower(u.Hostname()) |
| 50 | if strings.HasPrefix(host, "mcp.") || strings.HasPrefix(host, "mcp-") || strings.Contains(host, ".mcp.") || strings.Contains(host, "-mcp.") { |
| 51 | return true |
| 52 | } |
| 53 | p := strings.ToLower(u.Path) |
| 54 | return strings.Contains(p, "mcp") || strings.Contains(p, "sse") |
| 55 | } |
| 56 | |
| 57 | // rawGitHubBlobURL rewrites github.com/<owner>/<repo>/blob/<ref>/<path> (and |
| 58 | // the /raw/ variant) into the raw.githubusercontent.com form. Other URLs are |
| 59 | // returned untouched so we don't corrupt non-github sources. |
| 60 | func rawGitHubBlobURL(s string) string { |
| 61 | u, err := url.Parse(s) |
| 62 | if err != nil || !strings.EqualFold(u.Hostname(), "github.com") { |
| 63 | return s |
| 64 | } |
| 65 | parts := strings.Split(strings.Trim(u.Path, "/"), "/") |
| 66 | if len(parts) >= 5 && parts[2] == "blob" { |
| 67 | return "https://raw.githubusercontent.com/" + parts[0] + "/" + parts[1] + "/" + parts[3] + "/" + strings.Join(parts[4:], "/") |
| 68 | } |
| 69 | if len(parts) >= 5 && parts[2] == "raw" { |
| 70 | return "https://raw.githubusercontent.com/" + parts[0] + "/" + parts[1] + "/" + parts[3] + "/" + strings.Join(parts[4:], "/") |
| 71 | } |
| 72 | return s |
| 73 | } |
| 74 | |
| 75 | func looksLikePackage(s string) bool { |
| 76 | if strings.ContainsAny(s, " \t\n\\") || strings.HasPrefix(s, ".") || strings.HasPrefix(s, "/") { |
| 77 | return false |
| 78 | } |
| 79 | if strings.HasPrefix(s, "@") { |
| 80 | parts := strings.Split(s, "/") |
| 81 | return len(parts) == 2 && packageNameRe.MatchString(parts[0][1:]) && packageNameRe.MatchString(parts[1]) |
| 82 | } |
| 83 | return packageNameRe.MatchString(s) |
| 84 | } |
| 85 | |
| 86 | // isExecutable reports whether path is a regular executable file. POSIX uses |
| 87 | // execute bits; Windows uses executable file extensions because chmod bits do |
| 88 | // not reliably model launchability there. |
| 89 | func isExecutable(path string, info os.FileInfo) bool { |
| 90 | if !info.Mode().IsRegular() { |
| 91 | return false |
| 92 | } |
| 93 | if runtime.GOOS == "windows" { |
| 94 | switch strings.ToLower(filepath.Ext(path)) { |
| 95 | case ".exe", ".cmd", ".bat", ".ps1": |
| 96 | return true |
| 97 | } |
| 98 | } |
| 99 | return info.Mode().Perm()&0o111 != 0 |
| 100 | } |
| 101 | |
| 102 | // nameFromURL produces a stable human-readable skill name from a URL's |
| 103 | // filename stem. Falls back to "skill" when the URL has no name component. |
| 104 | func nameFromURL(s string) string { |
| 105 | u, err := url.Parse(s) |
| 106 | if err != nil { |
| 107 | return "skill" |
| 108 | } |
| 109 | base := filepath.Base(u.Path) |
| 110 | if ext := filepath.Ext(base); ext != "" { |
| 111 | base = strings.TrimSuffix(base, ext) |
| 112 | } |
| 113 | return sanitizeName(base) |
| 114 | } |
| 115 | |
| 116 | // mcpNameFromURL derives a default MCP server name from a remote URL. It |
| 117 | // strips common subdomains and TLDs so e.g. mcp.stripe.com -> "stripe" and |
| 118 | // api.example.co -> "example". localhost:port and explicit names work too. |
| 119 | func mcpNameFromURL(s string) string { |
| 120 | u, err := url.Parse(s) |
| 121 | if err != nil || u.Hostname() == "" { |
| 122 | return "mcp" |
| 123 | } |
| 124 | host := strings.ToLower(u.Hostname()) |
| 125 | if host == "localhost" || host == "127.0.0.1" || host == "::1" { |
| 126 | // Distinguish local servers by port; "localhost" alone is unhelpful. |
| 127 | if p := u.Port(); p != "" { |
| 128 | return sanitizeName("local-" + p) |
| 129 | } |
| 130 | return "local" |
| 131 | } |
| 132 | // Strip "mcp.", "api.", "www." subdomains and any "mcp-" / "mcp_" prefix. |
| 133 | for _, p := range []string{"mcp.", "api.", "www."} { |
| 134 | host = strings.TrimPrefix(host, p) |
| 135 | } |
| 136 | host = strings.TrimPrefix(host, "mcp-") |
| 137 | host = strings.TrimPrefix(host, "mcp_") |
| 138 | // Drop the TLD and any common second-level TLDs ("co.uk", "com.au"). |
| 139 | parts := strings.Split(host, ".") |
| 140 | switch len(parts) { |
| 141 | case 0, 1: |
| 142 | return sanitizeName(host) |
| 143 | case 2: |
| 144 | return sanitizeName(parts[0]) |
| 145 | default: |
| 146 | // Two-letter final segment is likely a ccTLD paired with a SLD |
| 147 | // (".co.uk", ".com.au", ".co.jp"). Use the third-to-last. |
| 148 | if len(parts[len(parts)-1]) == 2 { |
| 149 | return sanitizeName(parts[len(parts)-3]) |
| 150 | } |
| 151 | return sanitizeName(parts[len(parts)-2]) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // sanitizeName produces a valid skill/MCP identifier. Letters, digits, _ . - |
| 156 | // are kept; everything else becomes a single dash. Leading non-alphanumerics |
| 157 | // get an "mcp-" prefix so the result is always a valid config key. |
| 158 | func sanitizeName(s string) string { |
| 159 | s = strings.TrimSpace(strings.ToLower(s)) |
| 160 | s = strings.TrimPrefix(s, "@") |
| 161 | s = strings.ReplaceAll(s, "/", "-") |
| 162 | var b strings.Builder |
| 163 | prevDash := false |
| 164 | for _, r := range s { |
| 165 | ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '.' || r == '-' |
| 166 | if !ok { |
| 167 | if !prevDash { |
| 168 | b.WriteByte('-') |
| 169 | prevDash = true |
| 170 | } |
| 171 | continue |
| 172 | } |
| 173 | b.WriteRune(r) |
| 174 | prevDash = r == '-' |
| 175 | } |
| 176 | out := strings.Trim(b.String(), "-._") |
| 177 | if out == "" { |
| 178 | return "mcp" |
| 179 | } |
| 180 | if !((out[0] >= 'a' && out[0] <= 'z') || (out[0] >= '0' && out[0] <= '9')) { |
| 181 | out = "mcp-" + out |
| 182 | } |
| 183 | if len(out) > 64 { |
| 184 | out = out[:64] |
| 185 | out = strings.TrimRight(out, "-._") |
| 186 | } |
| 187 | return out |
| 188 | } |
| 189 | |
| 190 | // cleanMap drops empty keys. A nil/empty input returns nil so the JSON shape |
| 191 | // stays compact and downstream config compares equal. |
| 192 | func cleanMap(in map[string]string) map[string]string { |
| 193 | if len(in) == 0 { |
| 194 | return nil |
| 195 | } |
| 196 | out := map[string]string{} |
| 197 | for k, v := range in { |
| 198 | k = strings.TrimSpace(k) |
| 199 | if k != "" { |
| 200 | out[k] = v |
| 201 | } |
| 202 | } |
| 203 | if len(out) == 0 { |
| 204 | return nil |
| 205 | } |
| 206 | return out |
| 207 | } |
| 208 | |
| 209 | func collapseSpaces(s string) string { return strings.Join(strings.Fields(s), " ") } |
| 210 | |
| 211 | func firstNonEmpty(vs ...string) string { |
| 212 | for _, v := range vs { |
| 213 | if strings.TrimSpace(v) != "" { |
| 214 | return strings.TrimSpace(v) |
| 215 | } |
| 216 | } |
| 217 | return "" |
| 218 | } |
| 219 | |
| 220 | func normalizeKind(kind string) string { |
| 221 | switch strings.ToLower(strings.TrimSpace(kind)) { |
| 222 | case "skill", "mcp", "plugin": |
| 223 | return strings.ToLower(strings.TrimSpace(kind)) |
| 224 | default: |
| 225 | return "auto" |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | func normalizeMode(mode string) string { |
| 230 | switch strings.ToLower(strings.TrimSpace(mode)) { |
| 231 | case "copy", "link", "register": |
| 232 | return strings.ToLower(strings.TrimSpace(mode)) |
| 233 | default: |
| 234 | return "auto" |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | func modeForSingleSkill(mode string) string { |
| 239 | if mode == "link" { |
| 240 | return mode |
| 241 | } |
| 242 | return "copy" |
| 243 | } |
| 244 | |
| 245 | func normalizeTransport(t string) string { |
| 246 | switch strings.ToLower(strings.TrimSpace(t)) { |
| 247 | case "http", "streamable-http": |
| 248 | return "http" |
| 249 | case "sse": |
| 250 | return "sse" |
| 251 | case "stdio": |
| 252 | return "stdio" |
| 253 | default: |
| 254 | return "auto" |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | // normalizeTier maps a tier value into the supported set. The boolean returned |
| 259 | // reports whether the original value was already recognized; callers use it to |
| 260 | // surface a warning when a typo'd tier quietly becomes "background". |
| 261 | func normalizeTier(tier string) (string, bool) { |
| 262 | switch strings.ToLower(strings.TrimSpace(tier)) { |
| 263 | case "eager": |
| 264 | return "eager", true |
| 265 | case "background", "lazy": |
| 266 | return "background", true |
| 267 | case "": |
| 268 | return "background", true |
| 269 | default: |
| 270 | return "background", false |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | // pluginTransport reports the effective transport for a plugin entry, |
| 275 | // normalizing empty Type to stdio (the default the config layer expects). |
| 276 | func pluginTransport(e config.PluginEntry) string { |
| 277 | switch normalizeTransport(e.Type) { |
| 278 | case "http": |
| 279 | return "http" |
| 280 | case "sse": |
| 281 | return "sse" |
| 282 | default: |
| 283 | return "stdio" |
| 284 | } |
| 285 | } |
| 286 |