Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 49 additions & 20 deletions cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,29 @@ The rule will be downloaded and added to the rules.json file.
Rules are downloaded from the registry API using the GET endpoint
(e.g., api.continue.dev/v0/<owner-slug>/<rule-slug>/latest/download).

For GitHub repositories, use the gh: prefix followed by the owner/repo.
For example: gh:owner/repo
For GitHub repositories, use the gh: prefix followed by the owner/repo[/path/to/folder].
For example: gh:owner/repo or gh:owner/repo/path/to/specific/folder

When importing from GitHub repositories, the tool will:
- Download all files in the repository
- Download all files in the repository (or specific folder if path is provided)
- Use the main branch of the repository by default
- Look for rules.json in the downloaded files to find the version`,
Example: ` rules add vercel/nextjs
rules add redis
rules add gh:owner/repo`,
rules add gh:owner/repo
rules add gh:owner/repo/path/to/rules`,
Args: cobra.ExactArgs(1),
RunE: runAddCommand,
}

// RuleIdentifier contains the parsed components of a rule identifier
type RuleIdentifier struct {
OwnerSlug string
RuleSlug string
Version string
FullName string // The full name as it should appear in rules.json
OwnerSlug string
RuleSlug string
Version string
FullName string // The full name as it should appear in rules.json
SubPath string // For GitHub repos: path within the repository
RepoName string // For GitHub repos: the actual repository name
}

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

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

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

// Split owner/repo
// Split owner/repo/path...
repoParts := strings.Split(repoPath, "/")
if len(repoParts) != 2 {
return nil, fmt.Errorf("GitHub repository must be in format 'gh:owner/repo'")
if len(repoParts) < 2 {
return nil, fmt.Errorf("GitHub repository must be in format 'gh:owner/repo[/path/to/folder]'")
}

identifier.OwnerSlug = "gh:" + repoParts[0]
identifier.RuleSlug = repoParts[1]
owner := repoParts[0]
repo := repoParts[1]

identifier.OwnerSlug = "gh:" + owner
identifier.RepoName = repo
identifier.FullName = ruleArg

// If there are more parts, it's a subfolder path
if len(repoParts) > 2 {
identifier.SubPath = strings.Join(repoParts[2:], "/")
// Use the last folder name as the rule slug
identifier.RuleSlug = repoParts[len(repoParts)-1]
} else {
// Use the repo name as the rule slug for root-level rules
identifier.RuleSlug = repo
}

return identifier, nil
}

Expand Down Expand Up @@ -162,19 +178,32 @@ func loadOrCreateRuleSet(rulesJSONPath string) (*ruleset.RuleSet, error) {
// downloadRule downloads a rule from the registry
func downloadRule(client *registry.Client, identifier *RuleIdentifier, rulesDir string) (string, error) {
if strings.HasPrefix(identifier.FullName, "gh:") {
color.Cyan("Downloading rules from GitHub repository '%s'...", identifier.FullName[3:])
if identifier.SubPath != "" {
color.Cyan("Downloading rules from GitHub repository '%s' (path: %s)...", identifier.OwnerSlug[3:]+"/"+identifier.RepoName, identifier.SubPath)
if err := client.DownloadRuleFromGitHub(identifier.OwnerSlug[3:], identifier.RepoName, identifier.SubPath, rulesDir); err != nil {
return "", fmt.Errorf("failed to download rule: %w", err)
}
} else {
color.Cyan("Downloading rules from GitHub repository '%s'...", identifier.FullName[3:])
if err := client.DownloadRule(identifier.OwnerSlug, identifier.RuleSlug, identifier.Version, rulesDir); err != nil {
return "", fmt.Errorf("failed to download rule: %w", err)
}
}
} else {
color.Cyan("Downloading rule '%s/%s' (version %s) from registry API...", identifier.OwnerSlug, identifier.RuleSlug, identifier.Version)
}

if err := client.DownloadRule(identifier.OwnerSlug, identifier.RuleSlug, identifier.Version, rulesDir); err != nil {
return "", fmt.Errorf("failed to download rule: %w", err)
if err := client.DownloadRule(identifier.OwnerSlug, identifier.RuleSlug, identifier.Version, rulesDir); err != nil {
return "", fmt.Errorf("failed to download rule: %w", err)
}
}

// Check for the actual version in the downloaded rule.json file
var ruleDir string
if strings.HasPrefix(identifier.FullName, "gh:") {
ruleDir = filepath.Join(rulesDir, "gh:"+identifier.OwnerSlug[3:]+"/"+identifier.RuleSlug)
if identifier.SubPath != "" {
ruleDir = filepath.Join(rulesDir, "gh:"+identifier.OwnerSlug[3:]+"/"+identifier.RepoName+"/"+identifier.SubPath)
} else {
ruleDir = filepath.Join(rulesDir, "gh:"+identifier.OwnerSlug[3:]+"/"+identifier.RepoName)
}
} else {
ruleDir = filepath.Join(rulesDir, identifier.OwnerSlug, identifier.RuleSlug)
}
Expand Down
139 changes: 139 additions & 0 deletions cmd/add_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package cmd

import (
"testing"
)

func TestParseRuleIdentifier(t *testing.T) {
testCases := []struct {
name string
input string
expected *RuleIdentifier
hasError bool
}{
{
name: "GitHub repo root",
input: "gh:owner/repo",
expected: &RuleIdentifier{
OwnerSlug: "gh:owner",
RepoName: "repo",
RuleSlug: "repo",
SubPath: "",
Version: "latest",
FullName: "gh:owner/repo",
},
hasError: false,
},
{
name: "GitHub repo with subfolder",
input: "gh:owner/repo/path/to/folder",
expected: &RuleIdentifier{
OwnerSlug: "gh:owner",
RepoName: "repo",
RuleSlug: "folder",
SubPath: "path/to/folder",
Version: "latest",
FullName: "gh:owner/repo/path/to/folder",
},
hasError: false,
},
{
name: "GitHub repo with single subfolder",
input: "gh:owner/repo/rules",
expected: &RuleIdentifier{
OwnerSlug: "gh:owner",
RepoName: "repo",
RuleSlug: "rules",
SubPath: "rules",
Version: "latest",
FullName: "gh:owner/repo/rules",
},
hasError: false,
},
{
name: "GitHub repo with version",
input: "gh:owner/repo@v1.0.0",
expected: &RuleIdentifier{
OwnerSlug: "gh:owner",
RepoName: "repo",
RuleSlug: "repo",
SubPath: "",
Version: "v1.0.0",
FullName: "gh:owner/repo@v1.0.0",
},
hasError: false,
},
{
name: "GitHub repo with subfolder and version",
input: "gh:owner/repo/path/to/folder@v1.0.0",
expected: &RuleIdentifier{
OwnerSlug: "gh:owner",
RepoName: "repo",
RuleSlug: "folder",
SubPath: "path/to/folder",
Version: "v1.0.0",
FullName: "gh:owner/repo/path/to/folder@v1.0.0",
},
hasError: false,
},
{
name: "Invalid GitHub format",
input: "gh:owner",
expected: nil,
hasError: true,
},
{
name: "Registry rule",
input: "owner/rule",
expected: &RuleIdentifier{
OwnerSlug: "owner",
RuleSlug: "rule",
Version: "latest",
FullName: "owner/rule",
},
hasError: false,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := parseRuleIdentifier(tc.input)

if tc.hasError {
if err == nil {
t.Errorf("Expected error for input %s, but got none", tc.input)
}
return
}

if err != nil {
t.Errorf("Unexpected error for input %s: %v", tc.input, err)
return
}

if result.OwnerSlug != tc.expected.OwnerSlug {
t.Errorf("OwnerSlug mismatch for %s: got %s, expected %s", tc.input, result.OwnerSlug, tc.expected.OwnerSlug)
}

if result.RuleSlug != tc.expected.RuleSlug {
t.Errorf("RuleSlug mismatch for %s: got %s, expected %s", tc.input, result.RuleSlug, tc.expected.RuleSlug)
}

if result.Version != tc.expected.Version {
t.Errorf("Version mismatch for %s: got %s, expected %s", tc.input, result.Version, tc.expected.Version)
}

if result.FullName != tc.expected.FullName {
t.Errorf("FullName mismatch for %s: got %s, expected %s", tc.input, result.FullName, tc.expected.FullName)
}

if result.SubPath != tc.expected.SubPath {
t.Errorf("SubPath mismatch for %s: got %s, expected %s", tc.input, result.SubPath, tc.expected.SubPath)
}

if result.RepoName != tc.expected.RepoName {
t.Errorf("RepoName mismatch for %s: got %s, expected %s", tc.input, result.RepoName, tc.expected.RepoName)
}
})
}
}
4 changes: 2 additions & 2 deletions docs/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ This will add them to your project in a local `.rules` folder.
You can also download from GitHub rather than the rules registry:

```bash
rules add gh:continuedev/rules-template
rules add gh:continuedev/awesome-rules/ruby
```

## Render rules
Expand All @@ -57,7 +57,7 @@ rules publish

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

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).
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.

## Helping users use your rules

Expand Down
38 changes: 32 additions & 6 deletions internal/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func (c *Client) SetAuthToken(token string) {
func (c *Client) DownloadRule(ownerSlug, ruleSlug, version, formatDir string) error {
// Check if this is a GitHub repository
if strings.HasPrefix(ownerSlug, "gh:") {
return c.downloadFromGitHub(ownerSlug[3:]+"/"+ruleSlug, formatDir)
return c.downloadFromGitHub(ownerSlug[3:]+"/"+ruleSlug, "", formatDir)
}

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

// DownloadRuleFromGitHub downloads a rule from a GitHub repository with optional subpath
func (c *Client) DownloadRuleFromGitHub(owner, repo, subPath, formatDir string) error {
repoPath := owner + "/" + repo
return c.downloadFromGitHub(repoPath, subPath, formatDir)
}

// downloadFromGitHub downloads rules from a GitHub repository
func (c *Client) downloadFromGitHub(repoPath string, formatDir string) error {
func (c *Client) downloadFromGitHub(repoPath, subPath, formatDir string) error {
// Construct GitHub API URL to download zip of the main branch
url := fmt.Sprintf("https://api.github.com/repos/%s/zipball/main", repoPath)

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

// Create rule directory
ruleDir := filepath.Join(formatDir, "gh:"+repoPath)
var ruleDir string
if subPath != "" {
ruleDir = filepath.Join(formatDir, "gh:"+repoPath+"/"+subPath)
} else {
ruleDir = filepath.Join(formatDir, "gh:"+repoPath)
}
if err := os.MkdirAll(ruleDir, 0755); err != nil {
return fmt.Errorf("failed to create rule directory: %w", err)
}
Expand All @@ -291,7 +302,7 @@ func (c *Client) downloadFromGitHub(repoPath string, formatDir string) error {
return fmt.Errorf("could not determine repository structure")
}

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

// Get relative path without the repository prefix
relativePath := strings.TrimPrefix(file.Name, repoPrefix+"/")

// If subPath is specified, only include files within that path
if subPath != "" {
if !strings.HasPrefix(relativePath, subPath+"/") && relativePath != subPath {
continue
}
// Remove the subPath prefix from the relativePath for local storage
if strings.HasPrefix(relativePath, subPath+"/") {
relativePath = strings.TrimPrefix(relativePath, subPath+"/")
} else if relativePath == subPath {
// This is likely a file named exactly as the subPath
continue
}
}

// Open the file
src, err := file.Open()
if err != nil {
return fmt.Errorf("failed to open file from archive: %w", err)
}

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

// Create directory for file if needed
Expand Down