diff --git a/CLAUDE.md b/CLAUDE.md index df6956a..d011e3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview - This is a Go-based CLI tool for interacting with JuliaHub, a platform for Julia computing. The CLI provides commands for authentication, dataset management, registry management, project management, user information, token management, Git integration, and Julia integration. ## Architecture diff --git a/main.go b/main.go index 870b237..9c4e949 100644 --- a/main.go +++ b/main.go @@ -164,6 +164,7 @@ job execution, project management, Git integration, and package hosting capabili Available command categories: auth - Authentication and token management dataset - Dataset operations (list, download, upload, status) + package - Package search and exploration registry - Registry management (list registries) project - Project management (list, filter by user) user - User information and profile @@ -555,6 +556,121 @@ Displays: }, } +var packageCmd = &cobra.Command{ + Use: "package", + Short: "Package search commands", + Long: `Search and explore Julia packages on JuliaHub. + +Packages are Julia libraries that provide reusable functionality. JuliaHub +hosts packages from multiple registries and provides comprehensive search +capabilities including filtering by tags, installation status, failures, and more.`, +} + +var packageSearchCmd = &cobra.Command{ + Use: "search [search-term]", + Short: "Search for packages", + Long: `Search for Julia packages on JuliaHub. + +Displays package information including: +- Package name, owner, and UUID +- Version information +- Description and repository +- Tags and star count +- Installation status +- License information + +Filtering options: +- Filter by registry using --registries flag (searches all registries by default) +- Filter by installation status (--installed, --not-installed) +- Filter by packages with download failures (--has-failures) + +Use --verbose flag for comprehensive output, or get a concise summary by default.`, + Example: " jh package search dataframes\n jh package search --installed\n jh package search --verbose plots\n jh package search --limit 20 ml\n jh package search --registries General optimization", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + + search := "" + if len(args) > 0 { + search = args[0] + } + + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + verbose, _ := cmd.Flags().GetBool("verbose") + registryNamesStr, _ := cmd.Flags().GetString("registries") + + // Handle boolean flags - only set if explicitly provided + var installed *bool + var notInstalled *bool + var hasFailures *bool + + if cmd.Flags().Changed("installed") { + val, _ := cmd.Flags().GetBool("installed") + installed = &val + } + + if cmd.Flags().Changed("not-installed") { + val, _ := cmd.Flags().GetBool("not-installed") + notInstalled = &val + } + + if cmd.Flags().Changed("has-failures") { + val, _ := cmd.Flags().GetBool("has-failures") + hasFailures = &val + } + + // Fetch all registries from the API + allRegistries, err := fetchRegistries(server) + if err != nil { + fmt.Printf("Failed to fetch registries: %v\n", err) + os.Exit(1) + } + + // Determine which registry IDs to use + var registryIDs []int + if registryNamesStr != "" { + // Use only specified registries + requestedNames := strings.Split(registryNamesStr, ",") + for _, requestedName := range requestedNames { + requestedName = strings.TrimSpace(requestedName) + if requestedName == "" { + continue + } + + // Find matching registry (case-insensitive) + found := false + for _, reg := range allRegistries { + if strings.EqualFold(reg.Name, requestedName) { + registryIDs = append(registryIDs, reg.RegistryID) + found = true + break + } + } + + if !found { + fmt.Printf("Registry not found: '%s'\n", requestedName) + os.Exit(1) + } + } + } else { + // Use all registries + for _, reg := range allRegistries { + registryIDs = append(registryIDs, reg.RegistryID) + } + } + + if err := searchPackages(server, search, limit, offset, installed, notInstalled, hasFailures, registryIDs, verbose); err != nil { + fmt.Printf("Failed to search packages: %v\n", err) + os.Exit(1) + } + }, +} + var registryCmd = &cobra.Command{ Use: "registry", Short: "Registry management commands", @@ -1121,6 +1237,17 @@ func init() { datasetUploadCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") datasetUploadCmd.Flags().Bool("new", false, "Create a new dataset") datasetStatusCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") +<<<<<<< HEAD + packageSearchCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + packageSearchCmd.Flags().Int("limit", 10, "Maximum number of results to return") + packageSearchCmd.Flags().Int("offset", 0, "Number of results to skip") + packageSearchCmd.Flags().Bool("installed", false, "Filter by installed packages") + packageSearchCmd.Flags().Bool("not-installed", false, "Filter by not installed packages") + packageSearchCmd.Flags().Bool("has-failures", false, "Filter by packages with download failures") + packageSearchCmd.Flags().String("registries", "", "Filter by registry names (comma-separated, e.g., 'General,CustomRegistry')") + packageSearchCmd.Flags().Bool("verbose", false, "Show detailed package information") +======= +>>>>>>> 598769ba8b5d9204ddfa0992fd62b572c17226b4 registryListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") registryListCmd.Flags().Bool("verbose", false, "Show detailed registry information") projectListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") @@ -1139,6 +1266,7 @@ func init() { authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd) jobCmd.AddCommand(jobListCmd, jobStartCmd) datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) + packageCmd.AddCommand(packageSearchCmd) registryCmd.AddCommand(registryListCmd) projectCmd.AddCommand(projectListCmd) userCmd.AddCommand(userInfoCmd) @@ -1149,7 +1277,7 @@ func init() { runCmd.AddCommand(runSetupCmd) gitCredentialCmd.AddCommand(gitCredentialHelperCmd, gitCredentialGetCmd, gitCredentialStoreCmd, gitCredentialEraseCmd, gitCredentialSetupCmd) - rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, projectCmd, registryCmd, userCmd, adminCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) + rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, projectCmd, packageCmd, registryCmd, userCmd, adminCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) } func main() { diff --git a/package_search.gql b/package_search.gql new file mode 100644 index 0000000..33c7d80 --- /dev/null +++ b/package_search.gql @@ -0,0 +1,62 @@ +query FilteredPackages( + $search: String + $limit: Int + $offset: Int + $matchtags: _text + $registries: _int8 + $hasfailures: Boolean + $installed: Boolean + $notinstalled: Boolean + $licenses: _text + $order: [package_rank_vw_order_by!] + $filter: package_rank_vw_bool_exp = {} +) { + package_search( + args: { + search: $search + matchtags: $matchtags + licenses: $licenses + isinstalled: $installed + notinstalled: $notinstalled + hasfailures: $hasfailures + registrylist: $registries + } + order_by: $order + limit: $limit + offset: $offset + where: { _and: [{ fit: { _gte: 1 } }, $filter] } + ) { + name + owner + slug + license + isapp + score + registrymap { + version + registryid + status + isapp + isjsml + __typename + } + metadata { + docshosteduri + versions + description + docslink + repo + owner + tags + starcount + __typename + } + uuid + installed + failures { + package_version + __typename + } + __typename + } +} diff --git a/packages.go b/packages.go new file mode 100644 index 0000000..d87c3c7 --- /dev/null +++ b/packages.go @@ -0,0 +1,272 @@ +package main + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +//go:embed package_search.gql +var packageSearchFS embed.FS + +type PackageMetadata struct { + DocsHostedURI string `json:"docshosteduri"` + Versions []string `json:"versions"` + Description string `json:"description"` + DocsLink string `json:"docslink"` + Repo string `json:"repo"` + Owner string `json:"owner"` + Tags []string `json:"tags"` + StarCount int `json:"starcount"` +} + +type PackageRegistryMap struct { + Version string `json:"version"` + RegistryID int `json:"registryid"` + Status bool `json:"status"` + IsApp bool `json:"isapp"` + IsJSML *bool `json:"isjsml"` +} + +type PackageFailure struct { + PackageVersion string `json:"package_version"` +} + +type Package struct { + Name string `json:"name"` + Owner string `json:"owner"` + Slug *string `json:"slug"` + License string `json:"license"` + IsApp bool `json:"isapp"` + Score float64 `json:"score"` + RegistryMap *PackageRegistryMap `json:"registrymap"` + Metadata *PackageMetadata `json:"metadata"` + UUID string `json:"uuid"` + Installed bool `json:"installed"` + Failures []PackageFailure `json:"failures"` +} + +type PackageSearchResponse struct { + Data struct { + PackageSearch []Package `json:"package_search"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +func searchPackages(server string, search string, limit int, offset int, installed *bool, notInstalled *bool, hasFailures *bool, registries []int, verbose bool) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + // Read the GraphQL query from package_search.gql + queryBytes, err := packageSearchFS.ReadFile("package_search.gql") + if err != nil { + return fmt.Errorf("failed to read GraphQL query: %w", err) + } + query := string(queryBytes) + + // Build variables for the GraphQL query + variables := map[string]interface{}{ + "filter": map[string]interface{}{}, + "order": map[string]string{"score": "desc"}, + "matchtags": "{}", + "licenses": "{}", + "search": "", + "offset": 0, + "hasfailures": false, + "installed": true, + "notinstalled": true, + } + + if search != "" { + variables["search"] = search + } + + if limit > 0 { + variables["limit"] = limit + } + + if offset > 0 { + variables["offset"] = offset + } + + if installed != nil { + variables["installed"] = *installed + } + + if notInstalled != nil { + variables["notinstalled"] = *notInstalled + } + + if hasFailures != nil { + variables["hasfailures"] = *hasFailures + } + + if len(registries) > 0 { + // Convert registry IDs to strings + registryStrs := make([]string, len(registries)) + for i, id := range registries { + registryStrs[i] = fmt.Sprintf("%d", id) + } + // Format as PostgreSQL array: "{1,2,3}" + variables["registries"] = fmt.Sprintf("{%s}", strings.Join(registryStrs, ",")) + } + + graphqlReq := GraphQLRequest{ + OperationName: "FilteredPackages", + Query: query, + Variables: variables, + } + + jsonData, err := json.Marshal(graphqlReq) + if err != nil { + return fmt.Errorf("failed to marshal GraphQL request: %w", err) + } + + url := fmt.Sprintf("https://%s/v1/graphql", server) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Hasura-Role", "jhuser") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("GraphQL request failed (status %d): %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var response PackageSearchResponse + if err := json.Unmarshal(body, &response); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + // Check for GraphQL errors + if len(response.Errors) > 0 { + return fmt.Errorf("GraphQL errors: %v", response.Errors) + } + + packages := response.Data.PackageSearch + + if len(packages) == 0 { + fmt.Println("No packages found") + return nil + } + + fmt.Printf("Found %d package(s):\n\n", len(packages)) + + // Print column headers for concise output + if !verbose { + fmt.Printf("%-30s %-20s %-12s %s\n", "NAME", "OWNER", "VERSION", "DESCRIPTION") + fmt.Printf("%-30s %-20s %-12s %s\n", strings.Repeat("-", 30), strings.Repeat("-", 20), strings.Repeat("-", 12), strings.Repeat("-", 50)) + } + + for _, pkg := range packages { + if verbose { + // Verbose output with all details + fmt.Printf("Name: %s\n", pkg.Name) + fmt.Printf("UUID: %s\n", pkg.UUID) + fmt.Printf("Owner: %s\n", pkg.Owner) + + if pkg.Metadata != nil { + if pkg.Metadata.Description != "" { + fmt.Printf("Description: %s\n", pkg.Metadata.Description) + } + if pkg.Metadata.Repo != "" { + fmt.Printf("Repository: %s\n", pkg.Metadata.Repo) + } + if len(pkg.Metadata.Tags) > 0 { + fmt.Printf("Tags: %s\n", strings.Join(pkg.Metadata.Tags, ", ")) + } + if pkg.Metadata.StarCount > 0 { + fmt.Printf("Stars: %d\n", pkg.Metadata.StarCount) + } + if pkg.Metadata.DocsLink != "" { + fmt.Printf("Documentation: %s\n", pkg.Metadata.DocsLink) + } + } + + if pkg.License != "" { + fmt.Printf("License: %s\n", pkg.License) + } + + if pkg.RegistryMap != nil { + fmt.Printf("Latest Version: %s\n", pkg.RegistryMap.Version) + fmt.Printf("Status: ") + if pkg.RegistryMap.Status { + fmt.Printf("Active\n") + } else { + fmt.Printf("Inactive\n") + } + } + + fmt.Printf("Installed: %t\n", pkg.Installed) + + if pkg.IsApp { + fmt.Printf("Type: Application\n") + } + + if len(pkg.Failures) > 0 { + fmt.Printf("Failed Versions: ") + versions := make([]string, len(pkg.Failures)) + for i, failure := range pkg.Failures { + versions[i] = failure.PackageVersion + } + fmt.Printf("%s\n", strings.Join(versions, ", ")) + } + + fmt.Printf("Score: %.2f\n", pkg.Score) + } else { + // Concise output + fmt.Printf("%-30s %-20s", pkg.Name, pkg.Owner) + + if pkg.RegistryMap != nil { + fmt.Printf(" v%-10s", pkg.RegistryMap.Version) + } else { + fmt.Printf(" %-12s", "N/A") + } + + if pkg.Installed { + fmt.Printf(" [Installed]") + } + + if pkg.Metadata != nil && pkg.Metadata.Description != "" { + // Truncate description for concise view + desc := pkg.Metadata.Description + if len(desc) > 50 { + desc = desc[:50] + "..." + } + fmt.Printf("%s", desc) + } + + fmt.Printf("\n") + } + + fmt.Println() + } + + return nil +}