diff --git a/CLAUDE.md b/CLAUDE.md index 81231b1..cfe1777 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,8 @@ 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. + +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, package management, project management, user information, token management, Git integration, and Julia integration. ## Architecture @@ -13,7 +14,7 @@ The application follows a command-line interface pattern using the Cobra library - **auth.go**: OAuth2 device flow authentication with JWT token handling - **datasets.go**: Dataset operations (list, download, upload, status) with REST API integration - **registries.go**: Registry operations (list, config, add, update) with REST API integration -- **packages.go**: Package search with REST API primary path (`/packages/info`) and GraphQL fallback +- **packages.go**: Package operations (search, dependency) with REST API primary path (`/packages/info`), GraphQL fallback, and documentation API (`/docs/{registry}/{package}/stable/pkg.json`) - **projects.go**: Project management using GraphQL API with user filtering - **user.go**: User information retrieval using GraphQL API and REST API for listing users - **tokens.go**: Token management operations (list) with REST API integration @@ -42,9 +43,8 @@ The application follows a command-line interface pattern using the Cobra library - `jh dataset`: Dataset operations (list, download, upload, status) - `jh registry`: Registry operations (list, config — all via REST API) - `jh registry config`: Show registry JSON config by name; subcommands add/update accept JSON via stdin or `--file` - - `jh package`: Package search and info (REST primary via `/packages/info`, GraphQL fallback; supports filtering by registry) + - `jh package`: Package search and dependency (REST primary via `/packages/info`, GraphQL fallback; dependency data from `/docs/{registry}/{package}/stable/pkg.json`) - `jh project`: Project management (list with GraphQL, supports user filtering) - - `jh package`: Package search (REST primary via `/packages/info`, GraphQL fallback) - `jh user`: User information (info with GraphQL) - `jh admin`: Administrative commands (user management, token management, landing page) - `jh admin user`: User management (list all users with REST API, supports verbose mode) @@ -103,6 +103,11 @@ go run . package search --limit 20 ml go run . package search --registries General optimization go run . package info DataFrames go run . package info Plots --registries General + +# Get package dependencies +go run . package dependency DataFrames +go run . package dependency DataFrames --indirect +go run . package dependency CSV --registry General ``` ### Test registry operations @@ -230,9 +235,11 @@ The application uses OAuth2 device flow: ### REST API Integration - **Dataset operations**: Use presigned URLs for upload/download +- **Registry operations**: `/api/v1/registry/registries/descriptions` for listing registries - **User management**: `/app/config/features/manage` endpoint for listing all users - **Token management**: `/app/token/activelist` endpoint for listing all API tokens -- **Package search primary**: `/packages/info` endpoint with `name`, `registries`, `tags`, `licenses`, `limit`, `offset` query params; returns `{packages: [...], meta: {total: N}}` +- **Package search/info primary**: `/packages/info` endpoint with `name`, `registries`, `tags`, `licenses`, `limit`, `offset` query params; returns `{packages: [...], meta: {total: N}}` +- **Package dependencies**: `/docs/{registry}/{package}/stable/pkg.json` for dependency information - **Authentication**: Bearer token with ID token - **Upload workflow**: 3-step process (request presigned URL, upload to URL, close upload) @@ -286,6 +293,40 @@ git clone https://github.com/user/repo.git # Ignored by - **Token management**: Stores and refreshes tokens per server automatically - **Error handling**: Graceful fallback to other credential helpers for non-JuliaHub URLs +## Package Management + +The CLI provides comprehensive package discovery and dependency analysis: + +### Package Search and Info +- **Search**: `jh package search` uses GraphQL API to search packages across registries +- **Info**: `jh package info` retrieves detailed package metadata +- **Filtering**: Supports filtering by registry, installation status, and failures + +### Package Dependency (`jh package dependency`) +- **Endpoint**: Uses package documentation API at `/docs/{registry}/{package}/stable/pkg.json` +- **Registry resolution**: Automatically uses first registry package belongs to, or specific registry via `--registry` flag +- **Dependency types**: Distinguishes between direct and indirect dependencies via `direct` field in API response +- **Display limits**: + - Default: Shows up to 10 direct dependencies + - With `--indirect`: Shows up to 10 direct and 50 indirect dependencies +- **Output format**: + - Direct-only mode: Single table with columns: NAME, REGISTRY, UUID, VERSIONS + - Indirect mode: Separate sections for direct and indirect dependencies with columns: NAME, REGISTRY, UUID, VERSIONS + - Registry column shows which registry each dependency belongs to (empty for stdlib packages) + +#### Implementation Details (`packages.go`) +- `getPackageDependencies()`: Main function for dependency retrieval + 1. Fetches all registries to get registry IDs for GraphQL query + 2. Searches for package using GraphQL to get registry information + 3. Determines target registry (first registry or user-specified) + 4. Fetches package documentation JSON from docs endpoint + 5. Filters and limits dependencies based on flags + 6. Displays results in formatted tables with separate sections + +#### Data Structures +- `PackageDependency`: Represents a single dependency with fields for direct/indirect status, name, UUID, versions, registry, and slug +- `PackageDocsResponse`: Response from documentation API containing package metadata and dependencies array + ## Julia Integration The CLI provides Julia installation and execution with JuliaHub configuration: @@ -362,14 +403,15 @@ jh run setup - Landing page commands (`jh admin landing-page`) use REST API: GET `/app/homepage` (show), POST `/app/config/homepage` (update), DELETE `/app/config/homepage` (remove); require appropriate permissions - Landing page `update` command accepts content inline as an argument, from a file via `--file`, or piped via stdin (priority: `--file` > arg > stdin) - Landing page response uses custom JSON unmarshaling (`homepageResponse`) to handle `message` being either an object or a string -- Package search (`jh package search`) and info (`jh package info`) both try REST API (`/packages/info`) first, then fall back to GraphQL (`FilteredPackagesWithCount` via `/v1/graphql`) on failure; a warning is printed to stderr when the fallback is used +- Package search (`jh package search`) and info (`jh package info`) both try REST API (`/packages/info`) first, then fall back to GraphQL (`FilteredPackages` / `FilteredPackagesCount` via `/v1/graphql`) on failure; a warning is printed to stderr when the fallback is used - REST API passes `--registries` as comma-separated registry names to the `registries` query param; GraphQL fallback passes registry IDs to the `registries` variable -- `fetchRegistries` in `registries.go` is used by `listRegistries`, `packageSearchCmd`, and `packageInfoCmd` to resolve registry names to IDs (for GraphQL) and names (for REST) +- `fetchRegistries` in `registries.go` is used by `listRegistries`, `packageSearchCmd`, `packageInfoCmd`, and `packageDependencyCmd` to resolve registry names to IDs (for GraphQL) and names (for REST) - Both REST and GraphQL package search/info paths produce identical output columns (Registry and Owner); GraphQL resolves registry names from the `registryIDs`/`registryNames` already in `PackageSearchParams` — no extra API call needed - A package in multiple registries appears as multiple rows (one per registry) in both REST and GraphQL paths, since the GraphQL view (`package_rank_vw`) is already flattened per package-registry combination -- GraphQL fallback uses `package_search_with_count.gql` which fetches both the package list and aggregate count in a single request (`package_search` + `package_search_aggregate` root fields) +- GraphQL fallback uses `package_search.gql` (`FilteredPackages`) for the package list and `package_search_count.gql` (`FilteredPackagesCount`) for the aggregate count as separate requests - `executeGraphQL(server, token, req)` in `packages.go` is a shared helper for GraphQL POST requests (sets Authorization, Content-Type, Accept, X-Hasura-Role headers) - `getPackageInfo` in `packages.go` implements exact name-match lookup using REST-first (`getPackageInfoREST`), GraphQL fallback (`getPackageInfoGraphQL`); `packageInfoCmd` in `main.go` resolves registries via `fetchRegistries` +- `getPackageDependencies` uses GraphQL (`fetchGraphQLPackages`) to locate the package, then fetches `/docs/{registry}/{package}/stable/pkg.json` for dependency data; no REST fallback (docs endpoint is authoritative) ## Implementation Details diff --git a/README.md b/README.md index 390368b..5baa413 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A command-line interface for interacting with JuliaHub, a platform for Julia com - **Authentication**: OAuth2 device flow authentication with JWT token handling - **Dataset Management**: List, download, upload, and check status of datasets -- **Package Management**: Search and explore Julia packages across registries via REST API (GraphQL fallback) +- **Package Management**: Search and explore Julia packages across registries via REST API (GraphQL fallback), with dependency analysis - **Registry Management**: List, add, and update Julia package registries - **Project Management**: List and filter projects using GraphQL API - **Git Integration**: Clone, push, fetch, and pull with automatic JuliaHub authentication @@ -161,6 +161,10 @@ go build -o jh . - `--offset ` - Number of results to skip - `jh package info ` - Get detailed information about a specific package (exact name match, case-insensitive) - `jh package info --registries General` - Search in specific registries only +- `jh package dependency ` - List package dependencies + - Default: Shows up to 10 direct dependencies (NAME, REGISTRY, UUID, VERSIONS) + - `jh package dependency --indirect` - Include both direct and indirect dependencies + - `jh package dependency --registry General` - Specify registry to use ### Registry Management (`jh registry`) @@ -171,6 +175,7 @@ go build -o jh . - `jh registry config add` - Add a new registry (JSON payload via stdin or `--file`) - `jh registry config update` - Update an existing registry (same JSON schema as add, same flags) + ### Project Management (`jh project`) - `jh project list` - List all accessible projects @@ -274,6 +279,11 @@ jh package search --limit 20 --offset 0 ml # Get detailed info about a specific package jh package info DataFrames jh package info Plots --registries General + +# List package dependencies +jh package dependency DataFrames # Shows up to 10 direct dependencies +jh package dependency DataFrames --indirect # Includes indirect dependencies +jh package dependency CSV --registry General # Use specific registry ``` ### Registry Operations @@ -419,7 +429,7 @@ Note: Arguments after `--` are passed directly to Julia. The `jh run` command: - **Built with Go** using the Cobra CLI framework - **Authentication**: OAuth2 device flow with JWT token management -- **APIs**: REST API for datasets and package search/info (primary); GraphQL API for projects, user info, and package search/info fallback (single request returns results + total count) +- **APIs**: REST API for datasets and package search/info (primary); GraphQL API for projects, user info, package search/info fallback, and package dependency lookup - **Git Integration**: Seamless authentication via HTTP headers or credential helper - **Cross-platform**: Supports Windows, macOS, and Linux diff --git a/main.go b/main.go index 50d5dcb..dac96d0 100644 --- a/main.go +++ b/main.go @@ -818,6 +818,38 @@ The package name must match exactly (case-insensitive).`, }, } +var packageDependencyCmd = &cobra.Command{ + Use: "dependency ", + Short: "List package dependencies", + Long: `List dependencies for a specific Julia package. + +By default, shows all direct dependencies. Use --indirect flag to include +both direct and indirect dependencies. + +The command fetches dependency information from the package documentation +JSON endpoint. If a package exists in multiple registries, it uses the +first registry by default. You can specify a different registry using +the --registry flag.`, + Example: " jh package dependency DataFrames\n jh package dependency --indirect Plots\n jh package dependency --registry General CSV", + Args: cobra.ExactArgs(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) + } + + packageName := args[0] + registryName, _ := cmd.Flags().GetString("registry") + showIndirect, _ := cmd.Flags().GetBool("indirect") + + if err := getPackageDependencies(server, packageName, registryName, showIndirect); err != nil { + fmt.Printf("Failed to get package dependencies: %v\n", err) + os.Exit(1) + } + }, +} + var registryCmd = &cobra.Command{ Use: "registry", Short: "Registry management commands", @@ -1618,6 +1650,9 @@ func init() { packageSearchCmd.Flags().Bool("verbose", false, "Show detailed package information") packageInfoCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") packageInfoCmd.Flags().String("registries", "", "Filter by registry names (comma-separated, e.g., 'General,CustomRegistry')") + packageDependencyCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + packageDependencyCmd.Flags().String("registry", "", "Specify registry name (uses first registry if not specified)") + packageDependencyCmd.Flags().Bool("indirect", false, "Include indirect dependencies") 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") @@ -1640,7 +1675,7 @@ func init() { authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd) jobCmd.AddCommand(jobListCmd, jobStartCmd) datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) - packageCmd.AddCommand(packageSearchCmd, packageInfoCmd) + packageCmd.AddCommand(packageSearchCmd, packageInfoCmd, packageDependencyCmd) registryConfigCmd.Flags().StringP("server", "s", "", "JuliaHub server") registryConfigAddCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") registryConfigAddCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") diff --git a/packages.go b/packages.go index 22ee908..5ebed94 100644 --- a/packages.go +++ b/packages.go @@ -37,20 +37,6 @@ 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"` @@ -65,6 +51,35 @@ type PackageSearchResponse struct { } `json:"errors"` } +type PackageDependency struct { + Direct bool `json:"direct"` + Name string `json:"name"` + UUID string `json:"uuid"` + Versions []string `json:"versions"` + Registry string `json:"registry"` + Slug string `json:"slug"` +} + +type PackageDocsResponse struct { + Name string `json:"name"` + Version string `json:"version"` + Dependencies []PackageDependency `json:"deps"` +} + +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 RESTPackage struct { Name string `json:"name"` UUID string `json:"uuid"` @@ -474,3 +489,136 @@ func getPackageInfoGraphQL(server, packageName string, registryIDs []int, regist printPackages(matches, len(matches), true) return nil } + +func getPackageDependencies(server string, packageName string, registryName string, showIndirect bool) error { + var targetRegistry string + if registryName != "" { + targetRegistry = registryName + } else { + allRegistries, err := fetchRegistries(server) + if err != nil { + return fmt.Errorf("failed to fetch registries: %w", err) + } + var registryIDs []int + for _, reg := range allRegistries { + registryIDs = append(registryIDs, reg.RegistryID) + } + gqlPkgs, _, err := fetchGraphQLPackages(server, packageName, 100, 0, registryIDs) + if err != nil { + return fmt.Errorf("failed to search for package %q: %w", packageName, err) + } + for i := range gqlPkgs { + if strings.EqualFold(gqlPkgs[i].Name, packageName) { + if gqlPkgs[i].RegistryMap != nil { + for _, reg := range allRegistries { + if reg.RegistryID == gqlPkgs[i].RegistryMap.RegistryID { + targetRegistry = reg.Name + break + } + } + } + break + } + } + if targetRegistry == "" { + return fmt.Errorf("package not found: %s", packageName) + } + } + + docsURL := fmt.Sprintf("https://%s/docs/%s/%s/stable/pkg.json", server, targetRegistry, packageName) + + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + req, err := http.NewRequest("GET", docsURL, nil) + 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("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch package documentation: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API 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 docsResp PackageDocsResponse + if err := json.Unmarshal(body, &docsResp); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + var deps []PackageDependency + if showIndirect { + deps = docsResp.Dependencies + } else { + for _, dep := range docsResp.Dependencies { + if dep.Direct { + deps = append(deps, dep) + } + } + } + + if len(deps) == 0 { + if showIndirect { + fmt.Printf("Package %s (v%s) has no dependencies\n", docsResp.Name, docsResp.Version) + } else { + fmt.Printf("Package %s (v%s) has no direct dependencies\n", docsResp.Name, docsResp.Version) + } + return nil + } + + fmt.Printf("Dependencies for %s (v%s) from registry '%s':\n\n", docsResp.Name, docsResp.Version, targetRegistry) + if !showIndirect { + fmt.Printf("Showing %d direct dependencies (use --indirect to include indirect dependencies)\n\n", len(deps)) + fmt.Printf("%-35s %-15s %-38s %s\n", "NAME", "REGISTRY", "UUID", "VERSIONS") + fmt.Printf("%-35s %-15s %-38s %s\n", strings.Repeat("-", 35), strings.Repeat("-", 15), strings.Repeat("-", 38), strings.Repeat("-", 20)) + for _, dep := range deps { + fmt.Printf("%-35s %-15s %-38s %s\n", dep.Name, dep.Registry, dep.UUID, strings.Join(dep.Versions, ", ")) + } + } else { + var directDeps []PackageDependency + var indirectDeps []PackageDependency + for _, dep := range deps { + if dep.Direct { + directDeps = append(directDeps, dep) + } else { + indirectDeps = append(indirectDeps, dep) + } + } + fmt.Printf("Showing %d total dependencies (%d direct, %d indirect)\n\n", len(deps), len(directDeps), len(indirectDeps)) + if len(directDeps) > 0 { + fmt.Printf("Direct Dependencies (%d):\n", len(directDeps)) + fmt.Printf("%-35s %-15s %-38s %s\n", "NAME", "REGISTRY", "UUID", "VERSIONS") + fmt.Printf("%-35s %-15s %-38s %s\n", strings.Repeat("-", 35), strings.Repeat("-", 15), strings.Repeat("-", 38), strings.Repeat("-", 20)) + for _, dep := range directDeps { + fmt.Printf("%-35s %-15s %-38s %s\n", dep.Name, dep.Registry, dep.UUID, strings.Join(dep.Versions, ", ")) + } + fmt.Println() + } + if len(indirectDeps) > 0 { + fmt.Printf("Indirect Dependencies (%d):\n", len(indirectDeps)) + fmt.Printf("%-35s %-15s %-38s %s\n", "NAME", "REGISTRY", "UUID", "VERSIONS") + fmt.Printf("%-35s %-15s %-38s %s\n", strings.Repeat("-", 35), strings.Repeat("-", 15), strings.Repeat("-", 38), strings.Repeat("-", 20)) + for _, dep := range indirectDeps { + fmt.Printf("%-35s %-15s %-38s %s\n", dep.Name, dep.Registry, dep.UUID, strings.Join(dep.Versions, ", ")) + } + } + } + + return nil +}