Skip to content

Commit d7da585

Browse files
committed
chore: bump version to v0.8.0
1 parent 31a8002 commit d7da585

File tree

10 files changed

+260
-2
lines changed

10 files changed

+260
-2
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.8.0] - 2026-01-19
9+
10+
### Added
11+
- **SkillHub Integration**: Added support for searching and installing skills from [SkillHub.club](https://www.skillhub.club).
12+
- **Slug Resolution**: intelligent resolution of SkillHub slugs to GitHub install URLs.
13+
814
## [0.7.6] - 2026-01-19
915

1016
### Changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ ASK comes pre-configured with trusted sources:
132132
| **MATLAB** | Official [matlab/skills](https://github.com/matlab/skills) |
133133
| **OpenAI** | Official [openai/skills](https://github.com/openai/skills) |
134134
| **Superpowers** | [obra/superpowers](https://github.com/obra/superpowers) core library |
135+
| **SkillHub** | [SkillHub.club](https://www.skillhub.club) |
135136
| **Vercel** | [vercel-labs/agent-skills](https://github.com/vercel-labs/agent-skills) AI SDK skills |
136137

137138
## 📂 Installation Layout

cmd/install.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/yeasy/ask/internal/github"
1515
"github.com/yeasy/ask/internal/repository"
1616
"github.com/yeasy/ask/internal/skill"
17+
"github.com/yeasy/ask/internal/skillhub"
1718
)
1819

1920
// installCmd represents the install command
@@ -268,11 +269,66 @@ func installSingleSkill(input string, global bool, agents []string) error {
268269
} else {
269270
// It's a URL (e.g., https://github.com/xxx.git)
270271
repoURL = input
272+
if !strings.HasSuffix(repoURL, ".git") && !strings.HasPrefix(input, "http") {
273+
// It might be a SkillHub slug (no standard user/repo format easily distinguishable from just a name,
274+
// but usually slugs are kebab-case).
275+
// We can try to resolve it via SkillHub if it doesn't look like a URL.
276+
// However, `input` here in this block is usually "http..." because of `isURL` check?
277+
// Wait, `isURL` is true for http/git prefix.
278+
}
271279
urlParts := strings.Split(strings.TrimSuffix(repoURL, ".git"), "/")
272280
skillName = urlParts[len(urlParts)-1]
273281
}
274282
}
275283

284+
// SkillHub Slug Resolution
285+
// If it wasn't a valid GitHub URL and resolved to just a "name" or if we want to support direct slug install:
286+
// "ask skill install madappgang-claude-code-python"
287+
// This falls into the "else" of "check if input matches configured repository name" in the caller `Run` loop?
288+
// The `Run` loop checks configured repos. If not found, it passes `input` directly to `installSingleSkill`.
289+
// So `installSingleSkill` receives "madappgang-claude-code-python".
290+
// It goes to `else { Check if it's a direct URL or shorthand }`.
291+
// `isURL` = false.
292+
// `parts := strings.Split(input, "/")` -> len=1.
293+
// So it falls to `else { Standard install: owner/repo }` ? NO.
294+
// The code assumes input is "owner/repo" if it splits to 2?
295+
// Wait, let's look at `installSingleSkill` logic again.
296+
297+
/*
298+
if !isURL {
299+
parts := strings.Split(input, "/")
300+
if len(parts) > 2 {
301+
// subdir
302+
} else {
303+
// owner/repo
304+
repoURL = "https://github.com/" + input
305+
}
306+
}
307+
*/
308+
309+
// If input is "slug", `parts` has len 1.
310+
// The code `else { owner/repo }` logic (lines 262-267) assumes `len(parts) <= 2` handles owner/repo.
311+
// But if `len(parts) == 1`, `repoURL` becomes `https://github.com/slug`.
312+
// This is valid for GitHub user profile or org, but not a repo.
313+
314+
// We need to inject logic: if it looks like a slug (and not owner/repo), try SkillHub resolve.
315+
// Or try SkillHub resolve if GitHub check fails?
316+
// Doing it optimistically: if `strings.Contains(input, "/")` is false, it might be a slug.
317+
318+
if !strings.Contains(input, "/") && !strings.HasPrefix(input, "http") && !strings.HasPrefix(input, "git") {
319+
// Try resolving as SkillHub slug
320+
client := skillhub.NewClient()
321+
if resolved, err := client.Resolve(input); err == nil {
322+
fmt.Printf("Resolved SkillHub slug '%s' to '%s'\n", input, resolved)
323+
// Recursive call or set variables?
324+
// URL found (e.g. https://github.com/owner/repo#...)
325+
// Recursing is easiest to handle the new URL format (which might be a tree URL).
326+
return installSingleSkill(resolved, global, agents)
327+
}
328+
// If resolve fails, we proceed (it might be a local directory or something, though `ask` doesn't strictly support local paths yet in this function).
329+
// Or it falls through to be treated as `github.com/input` which will fail.
330+
}
331+
276332
// Use branch from version if not set from URL parsing
277333
if branch == "" && version != "" {
278334
branch = version

cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ the Agent ecosystem.`,
4545
// Uncomment the following line if your bare application
4646
// has an action associated with it:
4747
// Run: func(cmd *cobra.Command, args []string) { },
48-
Version: "0.7.6",
48+
Version: "0.8.0",
4949
}
5050

5151
// Execute adds all child commands to the root command and sets flags appropriately.

cmd/search.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/spf13/cobra"
1010
"github.com/yeasy/ask/internal/config"
1111
"github.com/yeasy/ask/internal/github"
12+
"github.com/yeasy/ask/internal/repository"
1213
"github.com/yeasy/ask/internal/ui"
1314
)
1415

@@ -90,6 +91,8 @@ You can provide an optional keyword to filter results (e.g. 'browser', 'python')
9091
repos = filtered
9192
}
9293
}
94+
case "skillhub":
95+
repos, err = repository.FetchSkillsFromSkillHub(keyword, "")
9396
}
9497

9598
// Set source name for each repo

docs/skill-sources.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ ASK comes with six pre-configured sources:
1717
| **OpenAI** | Official | `openai` | OpenAI Official Skills |
1818
| **MATLAB** | Official | `matlab` | MATLAB Official Skills |
1919
| **Composio** | Community | `composio` | Awesome Claude Skills |
20+
| **SkillHub** | Service | `skills` | SkillHub.club Search Service |
2021

2122
---
2223

internal/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ func DefaultConfig() Config {
166166
Type: "dir",
167167
URL: "vercel-labs/agent-skills",
168168
},
169+
{
170+
Name: "skillhub",
171+
Type: "skillhub",
172+
URL: "skills",
173+
},
169174
},
170175
}
171176
}

internal/repository/fetch.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/yeasy/ask/internal/git"
1111
"github.com/yeasy/ask/internal/github"
1212
"github.com/yeasy/ask/internal/skill"
13+
"github.com/yeasy/ask/internal/skillhub"
1314
)
1415

1516
// FetchSkills returns a list of skills available in the given repository
@@ -29,10 +30,11 @@ func FetchSkills(repo config.Repo) ([]github.Repository, error) {
2930
return github.SearchDir(owner, name, path)
3031
}
3132
return nil, fmt.Errorf("invalid repository URL format: %s", repo.URL)
33+
case "skillhub":
34+
return FetchSkillsFromSkillHub("", "")
3235
default:
3336
return nil, fmt.Errorf("unknown repository type: %s", repo.Type)
3437
}
35-
}
3638
}
3739

3840
// FetchSkillsViaGit clones a repo and discovers skills locally (no API needed)
@@ -113,3 +115,29 @@ func FetchSkillsViaGit(repo config.Repo) ([]github.Repository, error) {
113115

114116
return skills, nil
115117
}
118+
119+
// FetchSkillsFromSkillHub searches SkillHub and converts results to internal format
120+
func FetchSkillsFromSkillHub(query string, category string) ([]github.Repository, error) {
121+
client := skillhub.NewClient()
122+
skills, err := client.Search(query)
123+
if err != nil {
124+
return nil, err
125+
}
126+
127+
var repos []github.Repository
128+
for _, s := range skills {
129+
desc := s.Description
130+
// Use slug as the install argument for now.
131+
// Install command needs to detect if it's a slug and resolve it.
132+
repo := github.Repository{
133+
Name: s.Name,
134+
FullName: s.Slug, // Storing slug in FullName for easier access
135+
Description: desc,
136+
HTMLURL: s.Slug, // Using Slug as the "URL" that install command receives
137+
StargazersCount: s.Stars,
138+
Source: "skillhub",
139+
}
140+
repos = append(repos, repo)
141+
}
142+
return repos, nil
143+
}

internal/skillhub/client.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package skillhub
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io/ioutil"
7+
"net/http"
8+
"net/url"
9+
"regexp"
10+
"strings"
11+
"time"
12+
)
13+
14+
// SearchURL is the endpoint for quick search
15+
const SearchURL = "https://www.skillhub.club/api/search/quick"
16+
17+
// Skill represents a skill from SkillHub search
18+
type Skill struct {
19+
ID string `json:"id"`
20+
Name string `json:"name"`
21+
Slug string `json:"slug"`
22+
Description string `json:"description"`
23+
Category string `json:"category"`
24+
Author string `json:"author"`
25+
Stars int `json:"github_stars"`
26+
Tags []string `json:"tags"`
27+
}
28+
29+
type searchResponse struct {
30+
Skills []Skill `json:"skills"`
31+
}
32+
33+
// Client handles interaction with SkillHub
34+
type Client struct {
35+
HTTPClient *http.Client
36+
}
37+
38+
// NewClient creates a new SkillHub client
39+
func NewClient() *Client {
40+
return &Client{
41+
HTTPClient: &http.Client{
42+
Timeout: 10 * time.Second,
43+
},
44+
}
45+
}
46+
47+
// Search searches for skills on SkillHub
48+
func (c *Client) Search(query string) ([]Skill, error) {
49+
// If query is empty, use a generic term or handle differently,
50+
// but the API seems to require a query param or returns empty.
51+
// For "list all", perhaps we need the catalog API, but that requires auth.
52+
// Let's rely on search for now.
53+
if query == "" {
54+
query = "agent" // default search term if none provided?
55+
}
56+
57+
u, err := url.Parse(SearchURL)
58+
if err != nil {
59+
return nil, err
60+
}
61+
q := u.Query()
62+
q.Set("q", query)
63+
q.Set("limit", "50") // Fetch up to 50
64+
u.RawQuery = q.Encode()
65+
66+
resp, err := c.HTTPClient.Get(u.String())
67+
if err != nil {
68+
return nil, err
69+
}
70+
defer resp.Body.Close()
71+
72+
if resp.StatusCode != http.StatusOK {
73+
return nil, fmt.Errorf("SkillHub API returned status: %d", resp.StatusCode)
74+
}
75+
76+
var result searchResponse
77+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
78+
return nil, err
79+
}
80+
81+
return result.Skills, nil
82+
}
83+
84+
// Resolve fetches the GitHub URL for a given skill slug
85+
func (c *Client) Resolve(slug string) (string, error) {
86+
skillURL := fmt.Sprintf("https://www.skillhub.club/skills/%s", slug)
87+
resp, err := c.HTTPClient.Get(skillURL)
88+
if err != nil {
89+
return "", err
90+
}
91+
defer resp.Body.Close()
92+
93+
if resp.StatusCode != http.StatusOK {
94+
return "", fmt.Errorf("failed to fetch skill page: %d", resp.StatusCode)
95+
}
96+
97+
body, err := ioutil.ReadAll(resp.Body)
98+
if err != nil {
99+
return "", err
100+
}
101+
102+
// Look for GitHub link in the page content
103+
// Use regex or string searching.
104+
// We confirmed with curl that it appears as href="https://github.com/..."
105+
// A simple regex:
106+
re := regexp.MustCompile(`href="(https://github\.com/[^"]+)"`)
107+
matches := re.FindStringSubmatch(string(body))
108+
109+
if len(matches) > 1 {
110+
rawURL := matches[1]
111+
// clean fragments (e.g. #plugins...)
112+
if idx := strings.Index(rawURL, "#"); idx != -1 {
113+
rawURL = rawURL[:idx]
114+
}
115+
return rawURL, nil
116+
}
117+
118+
return "", fmt.Errorf("GitHub URL not found for skill: %s", slug)
119+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package skillhub_test
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
"github.com/yeasy/ask/internal/skillhub"
9+
)
10+
11+
func TestSkillHubLive(t *testing.T) {
12+
client := skillhub.NewClient()
13+
14+
// Test Search
15+
fmt.Println("Searching for 'python'...")
16+
skills, err := client.Search("python")
17+
if err != nil {
18+
t.Fatalf("Search failed: %v", err)
19+
}
20+
if len(skills) == 0 {
21+
t.Fatal("No skills found for 'python'")
22+
}
23+
fmt.Printf("Found %d skills. First: %s (Slug: %s)\n", len(skills), skills[0].Name, skills[0].Slug)
24+
25+
// Test Resolve
26+
slug := skills[0].Slug
27+
fmt.Printf("Resolving slug '%s'...\n", slug)
28+
url, err := client.Resolve(slug)
29+
if err != nil {
30+
t.Fatalf("Resolve failed: %v", err)
31+
}
32+
fmt.Printf("Resolved URL: %s\n", url)
33+
if url == "" {
34+
t.Fatal("Resolved URL is empty")
35+
}
36+
if strings.Contains(url, "#") {
37+
t.Fatalf("Resolved URL should not contain fragment: %s", url)
38+
}
39+
}

0 commit comments

Comments
 (0)