Skip to content

Commit f663c8a

Browse files
committed
feat: add local-first cache strategy for skill search
- Add ask repo sync command to clone/update repos to ~/.ask/repos/ - Search uses local cache first (fastest), fallback to API if empty - Add --local flag for offline-only search - Add --remote flag to force API search - Avoids GitHub API rate limiting
1 parent 22a3af9 commit f663c8a

File tree

3 files changed

+440
-36
lines changed

3 files changed

+440
-36
lines changed

cmd/search.go

Lines changed: 86 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"text/tabwriter"
88

99
"github.com/spf13/cobra"
10+
"github.com/yeasy/ask/internal/cache"
1011
"github.com/yeasy/ask/internal/config"
1112
"github.com/yeasy/ask/internal/github"
1213
"github.com/yeasy/ask/internal/repository"
@@ -17,28 +18,31 @@ import (
1718
var searchCmd = &cobra.Command{
1819
Use: "search [keyword]",
1920
Short: "Search for skills on GitHub",
20-
Long: `Search GitHub repositories with the 'agent-skill' topic.
21-
You can provide an optional keyword to filter results (e.g. 'browser', 'python').`,
22-
Example: ` # Search for browser-related skills
23-
ask skill search browser
21+
Long: `Search for skills matching the keyword.
22+
23+
By default, uses local cache if available (fastest), otherwise fetches from remote.
24+
Use --local to force local-only search.
25+
Use --remote to force remote API search.`,
26+
Example: ` # Search (local-first, then remote)
27+
ask skill search mcp
2428
25-
# Search for Python skills
26-
ask skill search python
29+
# Force local cache only (offline, fastest)
30+
ask skill search mcp --local
2731
28-
# Search all available skills
29-
ask skill search`,
32+
# Force remote API (latest data)
33+
ask skill search mcp --remote`,
3034
Run: func(cmd *cobra.Command, args []string) {
3135
keyword := ""
3236
if len(args) > 0 {
3337
keyword = strings.Join(args, " ")
3438
}
3539

36-
fmt.Printf("Searching for skills matching '%s'...\n", keyword)
40+
forceLocal, _ := cmd.Flags().GetBool("local")
41+
forceRemote, _ := cmd.Flags().GetBool("remote")
3742

3843
// Load config or use default
3944
cfg, err := config.LoadConfig()
4045
if err != nil {
41-
// It's okay if config doesn't exist, use default
4246
def := config.DefaultConfig()
4347
cfg = &def
4448
}
@@ -52,6 +56,45 @@ You can provide an optional keyword to filter results (e.g. 'browser', 'python')
5256
installedSkills[s.Name] = true
5357
}
5458

59+
var allRepos []github.Repository
60+
var errors []string
61+
var searchSource string
62+
63+
// Check local cache first (unless --remote is specified)
64+
if !forceRemote {
65+
reposCache, err := cache.NewReposCache()
66+
if err == nil {
67+
cachedRepos := reposCache.GetCachedRepos()
68+
if len(cachedRepos) > 0 || forceLocal {
69+
fmt.Printf("Searching local cache for '%s'...\n", keyword)
70+
skills, _ := reposCache.SearchSkills(keyword)
71+
72+
for _, skill := range skills {
73+
allRepos = append(allRepos, github.Repository{
74+
Name: skill.Name,
75+
Description: skill.Description,
76+
Source: "local:" + skill.RepoName,
77+
})
78+
}
79+
searchSource = "local"
80+
81+
if len(allRepos) > 0 || forceLocal {
82+
// Display results from local cache
83+
displaySearchResults(allRepos, installedSkills, searchSource)
84+
if forceLocal && len(allRepos) == 0 {
85+
fmt.Println("\nTip: Run 'ask repo sync' to populate local cache.")
86+
}
87+
return
88+
}
89+
// No local results and not forced local, fall through to remote
90+
}
91+
}
92+
}
93+
94+
// Remote search
95+
fmt.Printf("Searching for skills matching '%s'...\n", keyword)
96+
searchSource = "remote"
97+
5598
// Create progress bar for scanning sources
5699
bar := ui.NewProgressBar(len(cfg.Repos), "Scanning sources")
57100

@@ -104,8 +147,6 @@ You can provide an optional keyword to filter results (e.g. 'browser', 'python')
104147
}(repo)
105148
}
106149

107-
var allRepos []github.Repository
108-
var errors []string
109150
for i := 0; i < len(cfg.Repos); i++ {
110151
result := <-results
111152
_ = bar.Add(1)
@@ -119,41 +160,50 @@ You can provide an optional keyword to filter results (e.g. 'browser', 'python')
119160
fmt.Println()
120161
if len(errors) > 0 {
121162
fmt.Println("Warning: Some sources failed to load:")
122-
for _, err := range errors {
123-
fmt.Printf(" - %s\n", err)
163+
for _, errMsg := range errors {
164+
fmt.Printf(" - %s\n", errMsg)
124165
}
125166
fmt.Println()
126167
}
127168

128-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
129-
_, _ = fmt.Fprintln(w, "NAME\tSOURCE\tINSTALLED\tSTARS\tDESCRIPTION")
130-
for _, repo := range allRepos {
131-
// Truncate description if too long
132-
desc := repo.Description
133-
if len(desc) > 40 {
134-
desc = desc[:37] + "..."
135-
}
169+
displaySearchResults(allRepos, installedSkills, searchSource)
170+
},
171+
}
136172

137-
// Check if installed
138-
installed := ""
139-
if installedSkills[repo.Name] {
140-
installed = "✓"
141-
}
173+
func displaySearchResults(repos []github.Repository, installedSkills map[string]bool, source string) {
174+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
175+
_, _ = fmt.Fprintln(w, "NAME\tSOURCE\tINSTALLED\tSTARS\tDESCRIPTION")
176+
for _, repo := range repos {
177+
// Truncate description if too long
178+
desc := repo.Description
179+
if len(desc) > 40 {
180+
desc = desc[:37] + "..."
181+
}
142182

143-
// Format stars (use "-" for dir-based where stars are parent repo)
144-
stars := fmt.Sprintf("%d", repo.StargazersCount)
145-
if repo.StargazersCount == 0 {
146-
stars = "-"
147-
}
183+
// Check if installed
184+
installed := ""
185+
if installedSkills[repo.Name] {
186+
installed = ""
187+
}
148188

149-
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", repo.Name, repo.Source, installed, stars, desc)
189+
// Format stars (use "-" for local or dir-based)
190+
stars := fmt.Sprintf("%d", repo.StargazersCount)
191+
if repo.StargazersCount == 0 {
192+
stars = "-"
150193
}
151-
_ = w.Flush()
152194

153-
fmt.Printf("\nFound %d skills.\n", len(allRepos))
154-
},
195+
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", repo.Name, repo.Source, installed, stars, desc)
196+
}
197+
_ = w.Flush()
198+
199+
fmt.Printf("\nFound %d skills.\n", len(repos))
200+
if source == "local" {
201+
fmt.Println("(from local cache - run 'ask repo sync' to update)")
202+
}
155203
}
156204

157205
func init() {
158206
skillCmd.AddCommand(searchCmd)
207+
searchCmd.Flags().Bool("local", false, "search only local cache (offline)")
208+
searchCmd.Flags().Bool("remote", false, "force remote API search")
159209
}

cmd/sync.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
8+
"github.com/spf13/cobra"
9+
"github.com/yeasy/ask/internal/cache"
10+
"github.com/yeasy/ask/internal/config"
11+
)
12+
13+
// syncCmd represents the sync command
14+
var syncCmd = &cobra.Command{
15+
Use: "sync [repo-name]",
16+
Short: "Sync skill repositories to local cache",
17+
Long: `Clone or update skill repositories to local cache (~/.ask/repos/).
18+
This enables fast offline skill discovery without GitHub API rate limits.
19+
20+
If no repo name is specified, syncs all configured repositories.`,
21+
Example: ` ask repo sync # Sync all configured repos
22+
ask repo sync anthropics # Sync only anthropics repo
23+
ask repo sync superpowers # Sync only superpowers repo`,
24+
Run: func(cmd *cobra.Command, args []string) {
25+
reposCache, err := cache.NewReposCache()
26+
if err != nil {
27+
fmt.Printf("Error initializing repos cache: %v\n", err)
28+
os.Exit(1)
29+
}
30+
31+
// Load config to get repo list
32+
cfg, err := config.LoadConfig()
33+
if err != nil {
34+
// Use default config if not initialized
35+
def := config.DefaultConfig()
36+
cfg = &def
37+
}
38+
39+
// Filter repos if specific name provided
40+
var targetRepos []config.Repo
41+
if len(args) > 0 {
42+
repoName := strings.ToLower(args[0])
43+
for _, repo := range cfg.Repos {
44+
if strings.ToLower(repo.Name) == repoName {
45+
targetRepos = append(targetRepos, repo)
46+
break
47+
}
48+
}
49+
if len(targetRepos) == 0 {
50+
fmt.Printf("Repository '%s' not found in configuration.\n", args[0])
51+
fmt.Println("Available repos:")
52+
for _, r := range cfg.Repos {
53+
fmt.Printf(" - %s\n", r.Name)
54+
}
55+
os.Exit(1)
56+
}
57+
} else {
58+
// Sync all repos except topic-based ones
59+
for _, repo := range cfg.Repos {
60+
if repo.Type == "dir" {
61+
targetRepos = append(targetRepos, repo)
62+
}
63+
}
64+
}
65+
66+
if len(targetRepos) == 0 {
67+
fmt.Println("No repositories to sync.")
68+
return
69+
}
70+
71+
fmt.Printf("Syncing %d repositories to ~/.ask/repos/...\n\n", len(targetRepos))
72+
73+
successCount := 0
74+
for _, repo := range targetRepos {
75+
repoURL := buildRepoURL(repo.URL)
76+
repoName := buildRepoName(repo.URL)
77+
78+
err := reposCache.CloneOrPull(repoURL, repoName)
79+
if err != nil {
80+
fmt.Printf(" ✗ Failed to sync %s: %v\n", repo.Name, err)
81+
} else {
82+
fmt.Printf(" ✓ Synced %s\n", repo.Name)
83+
successCount++
84+
}
85+
}
86+
87+
fmt.Printf("\nSynced %d/%d repositories.\n", successCount, len(targetRepos))
88+
89+
// Save index
90+
if err := reposCache.SaveIndex(); err != nil {
91+
fmt.Printf("Warning: Failed to save index: %v\n", err)
92+
}
93+
94+
// Show cache location
95+
fmt.Printf("\nLocal cache: %s\n", cache.GetReposCacheDir())
96+
},
97+
}
98+
99+
// buildRepoURL constructs the git clone URL from repo config
100+
func buildRepoURL(url string) string {
101+
// Handle owner/repo format
102+
if !strings.HasPrefix(url, "http") && !strings.HasPrefix(url, "git@") {
103+
// Extract owner/repo from path like "anthropics/skills/skills"
104+
parts := strings.Split(url, "/")
105+
if len(parts) >= 2 {
106+
return fmt.Sprintf("https://github.com/%s/%s.git", parts[0], parts[1])
107+
}
108+
return "https://github.com/" + url + ".git"
109+
}
110+
return url
111+
}
112+
113+
// buildRepoName constructs a filesystem-safe name from repo URL
114+
func buildRepoName(url string) string {
115+
// Handle owner/repo/path format
116+
parts := strings.Split(url, "/")
117+
if len(parts) >= 2 {
118+
return parts[0] + "-" + parts[1]
119+
}
120+
return strings.ReplaceAll(url, "/", "-")
121+
}
122+
123+
func init() {
124+
repoCmd.AddCommand(syncCmd)
125+
}

0 commit comments

Comments
 (0)