Skip to content

Commit 3103e94

Browse files
authored
Merge pull request #14 from continuedev/nate/gh-sub-path
fix: ability to download from gh: subpath
2 parents 350359a + f32215f commit 3103e94

4 files changed

Lines changed: 222 additions & 28 deletions

File tree

cmd/add.go

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,29 @@ The rule will be downloaded and added to the rules.json file.
2626
Rules are downloaded from the registry API using the GET endpoint
2727
(e.g., api.continue.dev/v0/<owner-slug>/<rule-slug>/latest/download).
2828
29-
For GitHub repositories, use the gh: prefix followed by the owner/repo.
30-
For example: gh:owner/repo
29+
For GitHub repositories, use the gh: prefix followed by the owner/repo[/path/to/folder].
30+
For example: gh:owner/repo or gh:owner/repo/path/to/specific/folder
3131
3232
When importing from GitHub repositories, the tool will:
33-
- Download all files in the repository
33+
- Download all files in the repository (or specific folder if path is provided)
3434
- Use the main branch of the repository by default
3535
- Look for rules.json in the downloaded files to find the version`,
3636
Example: ` rules add vercel/nextjs
3737
rules add redis
38-
rules add gh:owner/repo`,
38+
rules add gh:owner/repo
39+
rules add gh:owner/repo/path/to/rules`,
3940
Args: cobra.ExactArgs(1),
4041
RunE: runAddCommand,
4142
}
4243

4344
// RuleIdentifier contains the parsed components of a rule identifier
4445
type RuleIdentifier struct {
45-
OwnerSlug string
46-
RuleSlug string
47-
Version string
48-
FullName string // The full name as it should appear in rules.json
46+
OwnerSlug string
47+
RuleSlug string
48+
Version string
49+
FullName string // The full name as it should appear in rules.json
50+
SubPath string // For GitHub repos: path within the repository
51+
RepoName string // For GitHub repos: the actual repository name
4952
}
5053

5154
// parseRuleIdentifier extracts the owner, rule slug, and version from the input argument
@@ -56,7 +59,7 @@ func parseRuleIdentifier(ruleArg string) (*RuleIdentifier, error) {
5659

5760
// Handle GitHub repositories
5861
if strings.HasPrefix(ruleArg, "gh:") {
59-
// Format: gh:owner/repo or gh:owner/repo@version
62+
// Format: gh:owner/repo[/path/to/folder][@version]
6063
repoPath := ruleArg[3:] // Remove "gh:" prefix
6164

6265
// Check for version
@@ -65,16 +68,29 @@ func parseRuleIdentifier(ruleArg string) (*RuleIdentifier, error) {
6568
identifier.Version = parts[1]
6669
}
6770

68-
// Split owner/repo
71+
// Split owner/repo/path...
6972
repoParts := strings.Split(repoPath, "/")
70-
if len(repoParts) != 2 {
71-
return nil, fmt.Errorf("GitHub repository must be in format 'gh:owner/repo'")
73+
if len(repoParts) < 2 {
74+
return nil, fmt.Errorf("GitHub repository must be in format 'gh:owner/repo[/path/to/folder]'")
7275
}
7376

74-
identifier.OwnerSlug = "gh:" + repoParts[0]
75-
identifier.RuleSlug = repoParts[1]
77+
owner := repoParts[0]
78+
repo := repoParts[1]
79+
80+
identifier.OwnerSlug = "gh:" + owner
81+
identifier.RepoName = repo
7682
identifier.FullName = ruleArg
7783

84+
// If there are more parts, it's a subfolder path
85+
if len(repoParts) > 2 {
86+
identifier.SubPath = strings.Join(repoParts[2:], "/")
87+
// Use the last folder name as the rule slug
88+
identifier.RuleSlug = repoParts[len(repoParts)-1]
89+
} else {
90+
// Use the repo name as the rule slug for root-level rules
91+
identifier.RuleSlug = repo
92+
}
93+
7894
return identifier, nil
7995
}
8096

@@ -162,19 +178,32 @@ func loadOrCreateRuleSet(rulesJSONPath string) (*ruleset.RuleSet, error) {
162178
// downloadRule downloads a rule from the registry
163179
func downloadRule(client *registry.Client, identifier *RuleIdentifier, rulesDir string) (string, error) {
164180
if strings.HasPrefix(identifier.FullName, "gh:") {
165-
color.Cyan("Downloading rules from GitHub repository '%s'...", identifier.FullName[3:])
181+
if identifier.SubPath != "" {
182+
color.Cyan("Downloading rules from GitHub repository '%s' (path: %s)...", identifier.OwnerSlug[3:]+"/"+identifier.RepoName, identifier.SubPath)
183+
if err := client.DownloadRuleFromGitHub(identifier.OwnerSlug[3:], identifier.RepoName, identifier.SubPath, rulesDir); err != nil {
184+
return "", fmt.Errorf("failed to download rule: %w", err)
185+
}
186+
} else {
187+
color.Cyan("Downloading rules from GitHub repository '%s'...", identifier.FullName[3:])
188+
if err := client.DownloadRule(identifier.OwnerSlug, identifier.RuleSlug, identifier.Version, rulesDir); err != nil {
189+
return "", fmt.Errorf("failed to download rule: %w", err)
190+
}
191+
}
166192
} else {
167193
color.Cyan("Downloading rule '%s/%s' (version %s) from registry API...", identifier.OwnerSlug, identifier.RuleSlug, identifier.Version)
168-
}
169-
170-
if err := client.DownloadRule(identifier.OwnerSlug, identifier.RuleSlug, identifier.Version, rulesDir); err != nil {
171-
return "", fmt.Errorf("failed to download rule: %w", err)
194+
if err := client.DownloadRule(identifier.OwnerSlug, identifier.RuleSlug, identifier.Version, rulesDir); err != nil {
195+
return "", fmt.Errorf("failed to download rule: %w", err)
196+
}
172197
}
173198

174199
// Check for the actual version in the downloaded rule.json file
175200
var ruleDir string
176201
if strings.HasPrefix(identifier.FullName, "gh:") {
177-
ruleDir = filepath.Join(rulesDir, "gh:"+identifier.OwnerSlug[3:]+"/"+identifier.RuleSlug)
202+
if identifier.SubPath != "" {
203+
ruleDir = filepath.Join(rulesDir, "gh:"+identifier.OwnerSlug[3:]+"/"+identifier.RepoName+"/"+identifier.SubPath)
204+
} else {
205+
ruleDir = filepath.Join(rulesDir, "gh:"+identifier.OwnerSlug[3:]+"/"+identifier.RepoName)
206+
}
178207
} else {
179208
ruleDir = filepath.Join(rulesDir, identifier.OwnerSlug, identifier.RuleSlug)
180209
}

cmd/add_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestParseRuleIdentifier(t *testing.T) {
8+
testCases := []struct {
9+
name string
10+
input string
11+
expected *RuleIdentifier
12+
hasError bool
13+
}{
14+
{
15+
name: "GitHub repo root",
16+
input: "gh:owner/repo",
17+
expected: &RuleIdentifier{
18+
OwnerSlug: "gh:owner",
19+
RepoName: "repo",
20+
RuleSlug: "repo",
21+
SubPath: "",
22+
Version: "latest",
23+
FullName: "gh:owner/repo",
24+
},
25+
hasError: false,
26+
},
27+
{
28+
name: "GitHub repo with subfolder",
29+
input: "gh:owner/repo/path/to/folder",
30+
expected: &RuleIdentifier{
31+
OwnerSlug: "gh:owner",
32+
RepoName: "repo",
33+
RuleSlug: "folder",
34+
SubPath: "path/to/folder",
35+
Version: "latest",
36+
FullName: "gh:owner/repo/path/to/folder",
37+
},
38+
hasError: false,
39+
},
40+
{
41+
name: "GitHub repo with single subfolder",
42+
input: "gh:owner/repo/rules",
43+
expected: &RuleIdentifier{
44+
OwnerSlug: "gh:owner",
45+
RepoName: "repo",
46+
RuleSlug: "rules",
47+
SubPath: "rules",
48+
Version: "latest",
49+
FullName: "gh:owner/repo/rules",
50+
},
51+
hasError: false,
52+
},
53+
{
54+
name: "GitHub repo with version",
55+
input: "gh:owner/repo@v1.0.0",
56+
expected: &RuleIdentifier{
57+
OwnerSlug: "gh:owner",
58+
RepoName: "repo",
59+
RuleSlug: "repo",
60+
SubPath: "",
61+
Version: "v1.0.0",
62+
FullName: "gh:owner/repo@v1.0.0",
63+
},
64+
hasError: false,
65+
},
66+
{
67+
name: "GitHub repo with subfolder and version",
68+
input: "gh:owner/repo/path/to/folder@v1.0.0",
69+
expected: &RuleIdentifier{
70+
OwnerSlug: "gh:owner",
71+
RepoName: "repo",
72+
RuleSlug: "folder",
73+
SubPath: "path/to/folder",
74+
Version: "v1.0.0",
75+
FullName: "gh:owner/repo/path/to/folder@v1.0.0",
76+
},
77+
hasError: false,
78+
},
79+
{
80+
name: "Invalid GitHub format",
81+
input: "gh:owner",
82+
expected: nil,
83+
hasError: true,
84+
},
85+
{
86+
name: "Registry rule",
87+
input: "owner/rule",
88+
expected: &RuleIdentifier{
89+
OwnerSlug: "owner",
90+
RuleSlug: "rule",
91+
Version: "latest",
92+
FullName: "owner/rule",
93+
},
94+
hasError: false,
95+
},
96+
}
97+
98+
for _, tc := range testCases {
99+
t.Run(tc.name, func(t *testing.T) {
100+
result, err := parseRuleIdentifier(tc.input)
101+
102+
if tc.hasError {
103+
if err == nil {
104+
t.Errorf("Expected error for input %s, but got none", tc.input)
105+
}
106+
return
107+
}
108+
109+
if err != nil {
110+
t.Errorf("Unexpected error for input %s: %v", tc.input, err)
111+
return
112+
}
113+
114+
if result.OwnerSlug != tc.expected.OwnerSlug {
115+
t.Errorf("OwnerSlug mismatch for %s: got %s, expected %s", tc.input, result.OwnerSlug, tc.expected.OwnerSlug)
116+
}
117+
118+
if result.RuleSlug != tc.expected.RuleSlug {
119+
t.Errorf("RuleSlug mismatch for %s: got %s, expected %s", tc.input, result.RuleSlug, tc.expected.RuleSlug)
120+
}
121+
122+
if result.Version != tc.expected.Version {
123+
t.Errorf("Version mismatch for %s: got %s, expected %s", tc.input, result.Version, tc.expected.Version)
124+
}
125+
126+
if result.FullName != tc.expected.FullName {
127+
t.Errorf("FullName mismatch for %s: got %s, expected %s", tc.input, result.FullName, tc.expected.FullName)
128+
}
129+
130+
if result.SubPath != tc.expected.SubPath {
131+
t.Errorf("SubPath mismatch for %s: got %s, expected %s", tc.input, result.SubPath, tc.expected.SubPath)
132+
}
133+
134+
if result.RepoName != tc.expected.RepoName {
135+
t.Errorf("RepoName mismatch for %s: got %s, expected %s", tc.input, result.RepoName, tc.expected.RepoName)
136+
}
137+
})
138+
}
139+
}

docs/docs/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ This will add them to your project in a local `.rules` folder.
3333
You can also download from GitHub rather than the rules registry:
3434

3535
```bash
36-
rules add gh:continuedev/rules-template
36+
rules add gh:continuedev/awesome-rules/ruby
3737
```
3838

3939
## Render rules
@@ -57,7 +57,7 @@ rules publish
5757

5858
This would make your rule available to download with `rules add <name-of-rules>`.
5959

60-
The command automatically determines the slug from your `rules.json` file. To make sure you have a `rules.json` file in your current directory, use `rules init`, or use our [template repository](https://github.com/continuedev/rules-template).
60+
The command automatically determines the slug from your `rules.json` file. To make sure you have a `rules.json` file in your current directory, use `rules init` or our [template repository](https://github.com/continuedev/rules-template), which includes a GitHub Action for publishing.
6161

6262
## Helping users use your rules
6363

internal/registry/registry.go

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func (c *Client) SetAuthToken(token string) {
6262
func (c *Client) DownloadRule(ownerSlug, ruleSlug, version, formatDir string) error {
6363
// Check if this is a GitHub repository
6464
if strings.HasPrefix(ownerSlug, "gh:") {
65-
return c.downloadFromGitHub(ownerSlug[3:]+"/"+ruleSlug, formatDir)
65+
return c.downloadFromGitHub(ownerSlug[3:]+"/"+ruleSlug, "", formatDir)
6666
}
6767

6868
// Use the registry API download endpoint
@@ -231,8 +231,14 @@ func (c *Client) PublishRule(ruleSlug, version, zipFilePath string, visibility s
231231
return nil
232232
}
233233

234+
// DownloadRuleFromGitHub downloads a rule from a GitHub repository with optional subpath
235+
func (c *Client) DownloadRuleFromGitHub(owner, repo, subPath, formatDir string) error {
236+
repoPath := owner + "/" + repo
237+
return c.downloadFromGitHub(repoPath, subPath, formatDir)
238+
}
239+
234240
// downloadFromGitHub downloads rules from a GitHub repository
235-
func (c *Client) downloadFromGitHub(repoPath string, formatDir string) error {
241+
func (c *Client) downloadFromGitHub(repoPath, subPath, formatDir string) error {
236242
// Construct GitHub API URL to download zip of the main branch
237243
url := fmt.Sprintf("https://api.github.com/repos/%s/zipball/main", repoPath)
238244

@@ -269,7 +275,12 @@ func (c *Client) downloadFromGitHub(repoPath string, formatDir string) error {
269275
}
270276

271277
// Create rule directory
272-
ruleDir := filepath.Join(formatDir, "gh:"+repoPath)
278+
var ruleDir string
279+
if subPath != "" {
280+
ruleDir = filepath.Join(formatDir, "gh:"+repoPath+"/"+subPath)
281+
} else {
282+
ruleDir = filepath.Join(formatDir, "gh:"+repoPath)
283+
}
273284
if err := os.MkdirAll(ruleDir, 0755); err != nil {
274285
return fmt.Errorf("failed to create rule directory: %w", err)
275286
}
@@ -291,7 +302,7 @@ func (c *Client) downloadFromGitHub(repoPath string, formatDir string) error {
291302
return fmt.Errorf("could not determine repository structure")
292303
}
293304

294-
// Download all files in the repository
305+
// Download files from the repository (filtered by subPath if provided)
295306
for _, file := range zipReader.File {
296307
// Skip directories, we'll create them as needed
297308
if file.FileInfo().IsDir() {
@@ -303,14 +314,29 @@ func (c *Client) downloadFromGitHub(repoPath string, formatDir string) error {
303314
continue
304315
}
305316

317+
// Get relative path without the repository prefix
318+
relativePath := strings.TrimPrefix(file.Name, repoPrefix+"/")
319+
320+
// If subPath is specified, only include files within that path
321+
if subPath != "" {
322+
if !strings.HasPrefix(relativePath, subPath+"/") && relativePath != subPath {
323+
continue
324+
}
325+
// Remove the subPath prefix from the relativePath for local storage
326+
if strings.HasPrefix(relativePath, subPath+"/") {
327+
relativePath = strings.TrimPrefix(relativePath, subPath+"/")
328+
} else if relativePath == subPath {
329+
// This is likely a file named exactly as the subPath
330+
continue
331+
}
332+
}
333+
306334
// Open the file
307335
src, err := file.Open()
308336
if err != nil {
309337
return fmt.Errorf("failed to open file from archive: %w", err)
310338
}
311339

312-
// Get destination path without the repository prefix
313-
relativePath := strings.TrimPrefix(file.Name, repoPrefix+"/")
314340
destPath := filepath.Join(ruleDir, relativePath)
315341

316342
// Create directory for file if needed

0 commit comments

Comments
 (0)