返回 DeepSeek-Reasonix
names.go
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
286 lines GO