99 "net/url"
1010 "os"
1111 "strings"
12+ "sync"
1213 "time"
1314
1415 "github.com/yeasy/ask/internal/cache"
@@ -20,10 +21,20 @@ const (
2021 SkillTopic = "agent-skill"
2122 // APIURL is the GitHub API endpoint for searching repositories
2223 APIURL = "https://api.github.com/search/repositories"
24+
25+ // httpTimeoutDefault is the default timeout for GitHub API requests
26+ httpTimeoutDefault = 10 * time .Second
27+ // httpTimeoutShort is a shorter timeout for non-critical requests like fetching descriptions
28+ httpTimeoutShort = 5 * time .Second
29+ // maxDescriptionReadBytes limits how much of SKILL.md we read for description extraction
30+ maxDescriptionReadBytes = 4096
2331)
2432
25- // Global cache instance
26- var searchCache * cache.Cache
33+ // Global cache instance, protected by cacheMu for concurrent access
34+ var (
35+ searchCache * cache.Cache
36+ cacheMu sync.RWMutex
37+ )
2738
2839// OfflineMode returns whether the application is in offline mode.
2940// Delegates to config.OfflineMode as the single source of truth.
@@ -41,6 +52,25 @@ func init() {
4152 }
4253}
4354
55+ // cacheGet safely reads from the global cache under a read lock.
56+ func cacheGet (key string , dest interface {}) bool {
57+ cacheMu .RLock ()
58+ defer cacheMu .RUnlock ()
59+ if searchCache == nil {
60+ return false
61+ }
62+ return searchCache .Get (key , dest )
63+ }
64+
65+ // cacheSet safely writes to the global cache under a write lock.
66+ func cacheSet (key string , value interface {}) {
67+ cacheMu .Lock ()
68+ defer cacheMu .Unlock ()
69+ if searchCache != nil {
70+ _ = searchCache .Set (key , value )
71+ }
72+ }
73+
4474// SearchResult represents the response from GitHub search API
4575type SearchResult struct {
4676 TotalCount int `json:"total_count"`
@@ -97,11 +127,9 @@ func SearchTopic(topic, keyword string) ([]Repository, error) {
97127
98128 // Try cache first
99129 // In offline mode, we MUST find it in cache or return error
100- if searchCache != nil {
101- var cached []Repository
102- if searchCache .Get (cacheKey , & cached ) {
103- return cached , nil
104- }
130+ var cached []Repository
131+ if cacheGet (cacheKey , & cached ) {
132+ return cached , nil
105133 }
106134
107135 if isOffline () {
@@ -129,7 +157,7 @@ func SearchTopic(topic, keyword string) ([]Repository, error) {
129157 req .Header .Set ("Accept" , "application/vnd.github.v3+json" )
130158 req .Header .Set ("User-Agent" , "ask-cli" )
131159
132- client := & http.Client {Timeout : 10 * time . Second }
160+ client := & http.Client {Timeout : httpTimeoutDefault }
133161 resp , err := client .Do (req )
134162 if err != nil {
135163 return nil , err
@@ -146,9 +174,7 @@ func SearchTopic(topic, keyword string) ([]Repository, error) {
146174 }
147175
148176 // Cache the result
149- if searchCache != nil {
150- _ = searchCache .Set (cacheKey , result .Items )
151- }
177+ cacheSet (cacheKey , result .Items )
152178
153179 return result .Items , nil
154180}
@@ -165,11 +191,9 @@ func SearchDir(owner, repo, path string) ([]Repository, error) {
165191 cacheKey := fmt .Sprintf ("dir:%s/%s/%s" , owner , repo , path )
166192
167193 // Try cache first
168- if searchCache != nil {
169- var cached []Repository
170- if searchCache .Get (cacheKey , & cached ) {
171- return cached , nil
172- }
194+ var cached []Repository
195+ if cacheGet (cacheKey , & cached ) {
196+ return cached , nil
173197 }
174198
175199 if isOffline () {
@@ -190,7 +214,7 @@ func SearchDir(owner, repo, path string) ([]Repository, error) {
190214 req .Header .Set ("Accept" , "application/vnd.github.v3+json" )
191215 req .Header .Set ("User-Agent" , "ask-cli" )
192216
193- client := & http.Client {Timeout : 10 * time . Second }
217+ client := & http.Client {Timeout : httpTimeoutDefault }
194218 resp , err := client .Do (req )
195219 if err != nil {
196220 return nil , err
@@ -240,9 +264,7 @@ func SearchDir(owner, repo, path string) ([]Repository, error) {
240264 }
241265
242266 // Cache the result
243- if searchCache != nil {
244- _ = searchCache .Set (cacheKey , skills )
245- }
267+ cacheSet (cacheKey , skills )
246268
247269 return skills , nil
248270}
@@ -251,11 +273,9 @@ func SearchDir(owner, repo, path string) ([]Repository, error) {
251273func fetchSkillDescription (owner , repo , skillPath string ) string {
252274 // Check cache first
253275 cacheKey := fmt .Sprintf ("skill-desc:%s/%s/%s" , owner , repo , skillPath )
254- if searchCache != nil {
255- var cached string
256- if searchCache .Get (cacheKey , & cached ) {
257- return cached
258- }
276+ var cached string
277+ if cacheGet (cacheKey , & cached ) {
278+ return cached
259279 }
260280
261281 // Fetch SKILL.md content
@@ -273,7 +293,7 @@ func fetchSkillDescription(owner, repo, skillPath string) string {
273293 req .Header .Set ("Accept" , "application/vnd.github.v3.raw" ) // Get raw file content
274294 req .Header .Set ("User-Agent" , "ask-cli" )
275295
276- client := & http.Client {Timeout : 5 * time . Second }
296+ client := & http.Client {Timeout : httpTimeoutShort }
277297 resp , err := client .Do (req )
278298 if err != nil {
279299 return ""
@@ -284,20 +304,19 @@ func fetchSkillDescription(owner, repo, skillPath string) string {
284304 return ""
285305 }
286306
287- // Read the content (limit to 4KB to avoid huge files)
288- buf := make ([]byte , 4096 )
289- n , err := io .ReadAtLeast (resp .Body , buf , 1 )
290- if err != nil && n == 0 {
307+ // Read the content (limit to maxDescriptionReadBytes to avoid huge files)
308+ data , err := io .ReadAll (io .LimitReader (resp .Body , maxDescriptionReadBytes ))
309+ if err != nil || len (data ) == 0 {
291310 return ""
292311 }
293- content := string (buf [: n ] )
312+ content := string (data )
294313
295314 // Parse description from SKILL.md (check both frontmatter and first paragraph)
296315 desc := parseDescriptionFromSkillMD (content )
297316
298317 // Cache the description
299- if searchCache != nil && desc != "" {
300- _ = searchCache . Set (cacheKey , desc )
318+ if desc != "" {
319+ cacheSet (cacheKey , desc )
301320 }
302321
303322 return desc
@@ -328,7 +347,7 @@ func parseDescriptionFromSkillMD(content string) string {
328347
329348 // If no frontmatter description, look for first non-empty non-heading line
330349 for _ , line := range lines {
331- line = trimSpace (line )
350+ line = strings . TrimSpace (line )
332351 if line == "" || line == "---" {
333352 continue
334353 }
@@ -342,36 +361,13 @@ func parseDescriptionFromSkillMD(content string) string {
342361 return ""
343362}
344363
345- // Helper functions to avoid importing strings package
364+ // splitLines splits a string into lines by newline character.
346365func splitLines (s string ) []string {
347- var lines []string
348- start := 0
349- for i := 0 ; i < len (s ); i ++ {
350- if s [i ] == '\n' {
351- lines = append (lines , s [start :i ])
352- start = i + 1
353- }
354- }
355- if start < len (s ) {
356- lines = append (lines , s [start :])
357- }
358- return lines
359- }
360-
361- func trimSpace (s string ) string {
362- start := 0
363- end := len (s )
364- for start < end && (s [start ] == ' ' || s [start ] == '\t' || s [start ] == '\r' ) {
365- start ++
366- }
367- for end > start && (s [end - 1 ] == ' ' || s [end - 1 ] == '\t' || s [end - 1 ] == '\r' ) {
368- end --
369- }
370- return s [start :end ]
366+ return strings .Split (s , "\n " )
371367}
372368
373369func trimQuotes (s string ) string {
374- s = trimSpace (s )
370+ s = strings . TrimSpace (s )
375371 if len (s ) >= 2 && ((s [0 ] == '"' && s [len (s )- 1 ] == '"' ) || (s [0 ] == '\'' && s [len (s )- 1 ] == '\'' )) {
376372 return s [1 : len (s )- 1 ]
377373 }
@@ -511,7 +507,7 @@ func FetchRepoDetails(owner, repo string) (*Repository, error) {
511507 req .Header .Set ("Accept" , "application/vnd.github.v3+json" )
512508 req .Header .Set ("User-Agent" , "ask-cli" )
513509
514- client := & http.Client {Timeout : 10 * time . Second }
510+ client := & http.Client {Timeout : httpTimeoutDefault }
515511 resp , err := client .Do (req )
516512 if err != nil {
517513 return nil , err
0 commit comments