Files
2026-07-13 13:00:08 +08:00

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