286 lines
7.9 KiB
Go
286 lines
7.9 KiB
Go
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/<owner>/<repo>/blob/<ref>/<path> (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"
|
|
}
|
|
}
|