diff --git a/cmd/add.go b/cmd/add.go index 6811efb..75df056 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -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///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 @@ -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 @@ -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 } @@ -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) } diff --git a/cmd/add_test.go b/cmd/add_test.go new file mode 100644 index 0000000..29cfcbf --- /dev/null +++ b/cmd/add_test.go @@ -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) + } + }) + } +} \ No newline at end of file diff --git a/docs/docs/index.md b/docs/docs/index.md index 324115d..a1cfd6e 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -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 @@ -57,7 +57,7 @@ rules publish This would make your rule available to download with `rules add `. -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 diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 3613bb2..2731e76 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -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 @@ -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) @@ -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) } @@ -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() { @@ -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