package installsource import ( "net/url" "os" "path/filepath" "regexp" "runtime" "strings" "reasonix/internal/config" ) // packageNameRe matches valid npm package-name segments. Pinned by [a-z0-9._-] // — exactly what npm allows. The leading character may be a digit (scoped // packages like @5/test are rare but valid). var packageNameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) func isURL(s string) bool { u, err := url.Parse(s) return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" } func looksLikeMarkdownURL(s string) bool { u, err := url.Parse(s) if err != nil { return false } return strings.EqualFold(filepath.Ext(u.Path), ".md") } func looksLikeMCPJSONURL(s string) bool { u, err := url.Parse(s) if err != nil { return false } return strings.EqualFold(filepath.Base(u.Path), ".mcp.json") } // looksLikeRemoteMCPEndpoint is a loose heuristic: any URL with "mcp" or // "sse" in its path, or a host that clearly advertises mcp, is treated as a // remote MCP endpoint when no manifest could be downloaded. Used as a fallback // for the auto-resolver; users can always set kind="mcp" to bypass the guess. func looksLikeRemoteMCPEndpoint(s string) bool { u, err := url.Parse(s) if err != nil { return false } host := strings.ToLower(u.Hostname()) if strings.HasPrefix(host, "mcp.") || strings.HasPrefix(host, "mcp-") || strings.Contains(host, ".mcp.") || strings.Contains(host, "-mcp.") { return true } p := strings.ToLower(u.Path) return strings.Contains(p, "mcp") || strings.Contains(p, "sse") } // rawGitHubBlobURL rewrites github.com///blob// (and // the /raw/ variant) into the raw.githubusercontent.com form. Other URLs are // returned untouched so we don't corrupt non-github sources. func rawGitHubBlobURL(s string) string { u, err := url.Parse(s) if err != nil || !strings.EqualFold(u.Hostname(), "github.com") { return s } parts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(parts) >= 5 && parts[2] == "blob" { return "https://raw.githubusercontent.com/" + parts[0] + "/" + parts[1] + "/" + parts[3] + "/" + strings.Join(parts[4:], "/") } if len(parts) >= 5 && parts[2] == "raw" { return "https://raw.githubusercontent.com/" + parts[0] + "/" + parts[1] + "/" + parts[3] + "/" + strings.Join(parts[4:], "/") } return s } func looksLikePackage(s string) bool { if strings.ContainsAny(s, " \t\n\\") || strings.HasPrefix(s, ".") || strings.HasPrefix(s, "/") { return false } if strings.HasPrefix(s, "@") { parts := strings.Split(s, "/") return len(parts) == 2 && packageNameRe.MatchString(parts[0][1:]) && packageNameRe.MatchString(parts[1]) } return packageNameRe.MatchString(s) } // isExecutable reports whether path is a regular executable file. POSIX uses // execute bits; Windows uses executable file extensions because chmod bits do // not reliably model launchability there. func isExecutable(path string, info os.FileInfo) bool { if !info.Mode().IsRegular() { return false } if runtime.GOOS == "windows" { switch strings.ToLower(filepath.Ext(path)) { case ".exe", ".cmd", ".bat", ".ps1": return true } } return info.Mode().Perm()&0o111 != 0 } // nameFromURL produces a stable human-readable skill name from a URL's // filename stem. Falls back to "skill" when the URL has no name component. func nameFromURL(s string) string { u, err := url.Parse(s) if err != nil { return "skill" } base := filepath.Base(u.Path) if ext := filepath.Ext(base); ext != "" { base = strings.TrimSuffix(base, ext) } return sanitizeName(base) } // mcpNameFromURL derives a default MCP server name from a remote URL. It // strips common subdomains and TLDs so e.g. mcp.stripe.com -> "stripe" and // api.example.co -> "example". localhost:port and explicit names work too. func mcpNameFromURL(s string) string { u, err := url.Parse(s) if err != nil || u.Hostname() == "" { return "mcp" } host := strings.ToLower(u.Hostname()) if host == "localhost" || host == "127.0.0.1" || host == "::1" { // Distinguish local servers by port; "localhost" alone is unhelpful. if p := u.Port(); p != "" { return sanitizeName("local-" + p) } return "local" } // Strip "mcp.", "api.", "www." subdomains and any "mcp-" / "mcp_" prefix. for _, p := range []string{"mcp.", "api.", "www."} { host = strings.TrimPrefix(host, p) } host = strings.TrimPrefix(host, "mcp-") host = strings.TrimPrefix(host, "mcp_") // Drop the TLD and any common second-level TLDs ("co.uk", "com.au"). parts := strings.Split(host, ".") switch len(parts) { case 0, 1: return sanitizeName(host) case 2: return sanitizeName(parts[0]) default: // Two-letter final segment is likely a ccTLD paired with a SLD // (".co.uk", ".com.au", ".co.jp"). Use the third-to-last. if len(parts[len(parts)-1]) == 2 { return sanitizeName(parts[len(parts)-3]) } return sanitizeName(parts[len(parts)-2]) } } // sanitizeName produces a valid skill/MCP identifier. Letters, digits, _ . - // are kept; everything else becomes a single dash. Leading non-alphanumerics // get an "mcp-" prefix so the result is always a valid config key. func sanitizeName(s string) string { s = strings.TrimSpace(strings.ToLower(s)) s = strings.TrimPrefix(s, "@") s = strings.ReplaceAll(s, "/", "-") var b strings.Builder prevDash := false for _, r := range s { ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '.' || r == '-' if !ok { if !prevDash { b.WriteByte('-') prevDash = true } continue } b.WriteRune(r) prevDash = r == '-' } out := strings.Trim(b.String(), "-._") if out == "" { return "mcp" } if !((out[0] >= 'a' && out[0] <= 'z') || (out[0] >= '0' && out[0] <= '9')) { out = "mcp-" + out } if len(out) > 64 { out = out[:64] out = strings.TrimRight(out, "-._") } return out } // cleanMap drops empty keys. A nil/empty input returns nil so the JSON shape // stays compact and downstream config compares equal. func cleanMap(in map[string]string) map[string]string { if len(in) == 0 { return nil } out := map[string]string{} for k, v := range in { k = strings.TrimSpace(k) if k != "" { out[k] = v } } if len(out) == 0 { return nil } return out } func collapseSpaces(s string) string { return strings.Join(strings.Fields(s), " ") } func firstNonEmpty(vs ...string) string { for _, v := range vs { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return "" } func normalizeKind(kind string) string { switch strings.ToLower(strings.TrimSpace(kind)) { case "skill", "mcp", "plugin": return strings.ToLower(strings.TrimSpace(kind)) default: return "auto" } } func normalizeMode(mode string) string { switch strings.ToLower(strings.TrimSpace(mode)) { case "copy", "link", "register": return strings.ToLower(strings.TrimSpace(mode)) default: return "auto" } } func modeForSingleSkill(mode string) string { if mode == "link" { return mode } return "copy" } func normalizeTransport(t string) string { switch strings.ToLower(strings.TrimSpace(t)) { case "http", "streamable-http": return "http" case "sse": return "sse" case "stdio": return "stdio" default: return "auto" } } // normalizeTier maps a tier value into the supported set. The boolean returned // reports whether the original value was already recognized; callers use it to // surface a warning when a typo'd tier quietly becomes "background". func normalizeTier(tier string) (string, bool) { switch strings.ToLower(strings.TrimSpace(tier)) { case "eager": return "eager", true case "background", "lazy": return "background", true case "": return "background", true default: return "background", false } } // pluginTransport reports the effective transport for a plugin entry, // normalizing empty Type to stdio (the default the config layer expects). func pluginTransport(e config.PluginEntry) string { switch normalizeTransport(e.Type) { case "http": return "http" case "sse": return "sse" default: return "stdio" } }