From 6ff761dcb196b014c697fa905ae679a2e2c7fcbd Mon Sep 17 00:00:00 2001 From: Panagiotis Georgakopoulos Date: Wed, 14 Oct 2026 19:50:59 +0300 Subject: [PATCH 1/4] feat: `--json` for `jh package info`, `dependency` and `dependents` Each prints only a JSON document on stdout, 2-space indented with a trailing newline: - `info`: `{"results": [...], "total": N}` holding the exact matches (one per registry); no match gives an empty `results`. - `dependency`: `{"name", "version", "registry", "dependencies": [...]}`; `--indirect` adds the indirect dependencies, each entry carries `direct`. - `dependents`: `{"name", "version", "registry", "direct", "indirect", "total", "dependents": [...]}`; the counts always cover every dependent, and `dependents` lists the indirect ones only with `--indirect`. `getPackageInfo` now collects the matches (REST, then GraphQL) and prints once. Errors from these three commands go to stderr, so stdout holds only the document. Backport of JuliaComputing/jh#69 (6186cca) to the 0.4 line, which has no `jh package search --json`. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 ++ README.md | 3 + main.go | 30 ++++++---- packages.go | 151 ++++++++++++++++++++++++++++++++++++----------- packages_test.go | 48 +++++++++++++++ 5 files changed, 189 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eb0d0f4..420f0fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,9 @@ go run . package dependency CSV --registry General # Get package dependents (reverse dependencies) go run . package dependents JuMP go run . package dependents JuMP --indirect +go run . package dependents JuMP --json | jq .total +go run . package dependency JuMP --json +go run . package info JuMP --json ``` ### Test registry operations @@ -512,6 +515,7 @@ jh run setup - 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 (`/api/v1/ui/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 +- `jh package info --json` prints the exact matches in the same `{"results": [...], "total": N}` document (`writePackagesJSON`; no match → empty `results`). `jh package dependency --json` prints `dependenciesDoc` and `jh package dependents --json` prints `dependentsDoc` (counts always cover every dependent; `dependents` lists indirect ones only with `--indirect`). All go through `writeJSON`; errors from these three commands go to stderr - 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`, `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 diff --git a/README.md b/README.md index 3cf929a..c12e519 100644 --- a/README.md +++ b/README.md @@ -164,14 +164,17 @@ 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 info --json` - Print only `{"results": [...], "total": N}` on stdout - `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 + - `jh package dependency --json` - Print only `{"name", "version", "registry", "dependencies": [...]}` on stdout (`--indirect` adds the indirect ones) - `jh package dependents ` - List packages that depend on a package (reverse dependencies) - Default: Prints direct/indirect/total counts and lists the direct dependents (NAME, REGISTRY, UUID) - `jh package dependents --indirect` - Also list indirect dependents - `jh package dependents --registry General` - Specify registry to use + - `jh package dependents --json` - Print only `{"name", "version", "registry", "direct", "indirect", "total", "dependents": [...]}` on stdout; the counts always cover every dependent, `dependents` lists the indirect ones only with `--indirect` ### Registry Management (`jh registry`) diff --git a/main.go b/main.go index f4f7bd7..c1ac174 100644 --- a/main.go +++ b/main.go @@ -1006,16 +1006,17 @@ Shows comprehensive package information including: - Documentation links The package name must match exactly (case-insensitive).`, - Example: " jh package info DataFrames\n jh package info Plots\n jh package info CSV", + Example: " jh package info DataFrames\n jh package info Plots\n jh package info --json 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) + fmt.Fprintf(os.Stderr, "Failed to get server config: %v\n", err) os.Exit(1) } packageName := args[0] + asJSON, _ := cmd.Flags().GetBool("json") registryNamesStr, _ := cmd.Flags().GetString("registries") // Fetch all registries from the API @@ -1057,8 +1058,8 @@ The package name must match exactly (case-insensitive).`, } } - if err := getPackageInfo(server, packageName, registryIDs, registryNames); err != nil { - fmt.Printf("Failed to get package info: %v\n", err) + if err := getPackageInfo(server, packageName, registryIDs, registryNames, asJSON); err != nil { + fmt.Fprintf(os.Stderr, "Failed to get package info: %v\n", err) os.Exit(1) } }, @@ -1076,21 +1077,22 @@ 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", + Example: " jh package dependency DataFrames\n jh package dependency --indirect Plots\n jh package dependency --registry General CSV\n jh package dependency --json JuMP", 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) + fmt.Fprintf(os.Stderr, "Failed to get server config: %v\n", err) os.Exit(1) } packageName := args[0] + asJSON, _ := cmd.Flags().GetBool("json") 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) + if err := getPackageDependencies(server, packageName, registryName, showIndirect, asJSON); err != nil { + fmt.Fprintf(os.Stderr, "Failed to get package dependencies: %v\n", err) os.Exit(1) } }, @@ -1109,21 +1111,22 @@ The data comes from the same package documentation JSON endpoint as jh package dependency, for the package's stable version. 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 dependents JuMP\n jh package dependents --indirect DataFrames\n jh package dependents --registry General CSV", + Example: " jh package dependents JuMP\n jh package dependents --indirect DataFrames\n jh package dependents --registry General CSV\n jh package dependents --json JuMP", 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) + fmt.Fprintf(os.Stderr, "Failed to get server config: %v\n", err) os.Exit(1) } packageName := args[0] + asJSON, _ := cmd.Flags().GetBool("json") registryName, _ := cmd.Flags().GetString("registry") showIndirect, _ := cmd.Flags().GetBool("indirect") - if err := getPackageDependents(server, packageName, registryName, showIndirect); err != nil { - fmt.Printf("Failed to get package dependents: %v\n", err) + if err := getPackageDependents(server, packageName, registryName, showIndirect, asJSON); err != nil { + fmt.Fprintf(os.Stderr, "Failed to get package dependents: %v\n", err) os.Exit(1) } }, @@ -2488,12 +2491,15 @@ 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')") + packageInfoCmd.Flags().Bool("json", false, "Print only a JSON document ({\"results\": [...], \"total\": N}) on stdout") 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") + packageDependencyCmd.Flags().Bool("json", false, "Print only a JSON document ({\"name\", \"version\", \"registry\", \"dependencies\": [...]}) on stdout") packageDependentsCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") packageDependentsCmd.Flags().String("registry", "", "Specify registry name (uses first registry if not specified)") packageDependentsCmd.Flags().Bool("indirect", false, "Also list indirect dependents") + packageDependentsCmd.Flags().Bool("json", false, "Print only a JSON document ({\"name\", \"version\", \"registry\", \"direct\", \"indirect\", \"total\", \"dependents\": [...]}) on stdout") 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") diff --git a/packages.go b/packages.go index cdc70e3..12183cb 100644 --- a/packages.go +++ b/packages.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "os" "sort" "strings" "time" @@ -128,20 +129,49 @@ type PackageSearchParams struct { // packageInfo is a common display struct used by both REST and GraphQL paths. type packageInfo struct { - Name string - UUID string - Owner string - Registry string - Version string - Description string - SourceURL string - Tags []string - Stars int - DocsURL string - License string - IsApp bool - Score float64 - Status string + Name string `json:"name"` + UUID string `json:"uuid"` + Owner string `json:"owner"` + Registry string `json:"registry"` + Version string `json:"version"` + Description string `json:"description"` + SourceURL string `json:"source_url"` + Tags []string `json:"tags"` + Stars int `json:"stars"` + DocsURL string `json:"docs_url"` + License string `json:"license"` + IsApp bool `json:"is_app"` + Score float64 `json:"score"` + Status string `json:"status"` +} + +// writeJSON writes doc 2-space indented with a trailing newline — the only +// thing on stdout in --json mode. +func writeJSON(w io.Writer, doc interface{}) error { + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return fmt.Errorf("failed to encode JSON: %w", err) + } + _, err = w.Write(append(out, '\n')) + return err +} + +// writePackagesJSON writes `{"results": [...], "total": N}` with writeJSON. +// Tags is always an array (never null) so consumers can iterate it +// unconditionally. +func writePackagesJSON(w io.Writer, pkgs []packageInfo, total int) error { + results := make([]packageInfo, len(pkgs)) + for i, p := range pkgs { + if p.Tags == nil { + p.Tags = []string{} + } + results[i] = p + } + doc := struct { + Results []packageInfo `json:"results"` + Total int `json:"total"` + }{results, total} + return writeJSON(w, doc) } func printPackages(pkgs []packageInfo, total int, verbose bool) { @@ -606,17 +636,31 @@ func executeGraphQL(server string, token *StoredToken, gqlReq GraphQLRequest) ([ return body, nil } -func getPackageInfo(server, packageName string, registryIDs []int, registryNames []string) error { - if err := getPackageInfoREST(server, packageName, registryNames); err != nil { - return getPackageInfoGraphQL(server, packageName, registryIDs, registryNames) +// getPackageInfo prints the packages named packageName (exact, +// case-insensitive), one per registry, as a verbose listing or, with asJSON, +// as `{"results": [...], "total": N}`. +func getPackageInfo(server, packageName string, registryIDs []int, registryNames []string, asJSON bool) error { + matches, err := findPackageInfoREST(server, packageName, registryNames) + if err != nil { + if matches, err = findPackageInfoGraphQL(server, packageName, registryIDs, registryNames); err != nil { + return err + } } + if asJSON { + return writePackagesJSON(os.Stdout, matches, len(matches)) + } + if len(matches) == 0 { + fmt.Println("Package not found") + return nil + } + printPackages(matches, len(matches), true) return nil } -func getPackageInfoREST(server, packageName string, registryNames []string) error { +func findPackageInfoREST(server, packageName string, registryNames []string) ([]packageInfo, error) { pkgs, _, err := fetchRESTPackages(server, packageName, 100, 0, registryNames) if err != nil { - return err + return nil, err } var matches []packageInfo for _, p := range pkgs { @@ -624,18 +668,13 @@ func getPackageInfoREST(server, packageName string, registryNames []string) erro matches = append(matches, restToInfo(p)) } } - if len(matches) == 0 { - fmt.Println("Package not found") - return nil - } - printPackages(matches, len(matches), true) - return nil + return matches, nil } -func getPackageInfoGraphQL(server, packageName string, registryIDs []int, registryNames []string) error { +func findPackageInfoGraphQL(server, packageName string, registryIDs []int, registryNames []string) ([]packageInfo, error) { pkgs, err := fetchGraphQLPackages(server, packageName, 100, 0, registryIDs) if err != nil { - return err + return nil, err } registryIDToName := buildRegistryIDToName(registryIDs, registryNames) var matches []packageInfo @@ -644,12 +683,7 @@ func getPackageInfoGraphQL(server, packageName string, registryIDs []int, regist matches = append(matches, gqlToInfo(p, registryIDToName)) } } - if len(matches) == 0 { - fmt.Println("Package not found") - return nil - } - printPackages(matches, len(matches), true) - return nil + return matches, nil } // findPackageRegistry returns the registry of the package named packageName @@ -740,7 +774,15 @@ func fetchPackageDocs(server, packageName, registryName string) (*PackageDocsRes return &docsResp, targetRegistry, nil } -func getPackageDependencies(server string, packageName string, registryName string, showIndirect bool) error { +// dependenciesDoc is the --json output of `jh package dependency`. +type dependenciesDoc struct { + Name string `json:"name"` + Version string `json:"version"` + Registry string `json:"registry"` + Dependencies []PackageDependency `json:"dependencies"` +} + +func getPackageDependencies(server string, packageName string, registryName string, showIndirect, asJSON bool) error { docsResp, targetRegistry, err := fetchPackageDocs(server, packageName, registryName) if err != nil { return err @@ -757,6 +799,13 @@ func getPackageDependencies(server string, packageName string, registryName stri } } + if asJSON { + if deps == nil { + deps = []PackageDependency{} + } + return writeJSON(os.Stdout, dependenciesDoc{docsResp.Name, docsResp.Version, targetRegistry, deps}) + } + if len(deps) == 0 { if showIndirect { fmt.Printf("Package %s (v%s) has no dependencies\n", docsResp.Name, docsResp.Version) @@ -846,13 +895,45 @@ func printDependentsTable(title string, deps []PackageDependent) { } } -func getPackageDependents(server, packageName, registryName string, showIndirect bool) error { +// dependentsDoc is the --json output of `jh package dependents`. The counts +// cover every dependent; Dependents lists the indirect ones only with +// --indirect. +type dependentsDoc struct { + Name string `json:"name"` + Version string `json:"version"` + Registry string `json:"registry"` + Direct int `json:"direct"` + Indirect int `json:"indirect"` + Total int `json:"total"` + Dependents []PackageDependent `json:"dependents"` +} + +func newDependentsDoc(docsResp *PackageDocsResponse, registry string, direct, indirect []PackageDependent, showIndirect bool) dependentsDoc { + listed := append([]PackageDependent{}, direct...) + if showIndirect { + listed = append(listed, indirect...) + } + return dependentsDoc{ + Name: docsResp.Name, + Version: docsResp.Version, + Registry: registry, + Direct: len(direct), + Indirect: len(indirect), + Total: len(direct) + len(indirect), + Dependents: listed, + } +} + +func getPackageDependents(server, packageName, registryName string, showIndirect, asJSON bool) error { docsResp, targetRegistry, err := fetchPackageDocs(server, packageName, registryName) if err != nil { return err } direct, indirect := splitDependents(docsResp.Dependents) + if asJSON { + return writeJSON(os.Stdout, newDependentsDoc(docsResp, targetRegistry, direct, indirect, showIndirect)) + } if len(direct)+len(indirect) == 0 { fmt.Printf("Package %s (v%s) has no dependents\n", docsResp.Name, docsResp.Version) return nil diff --git a/packages_test.go b/packages_test.go index 7dce95d..06afbcf 100644 --- a/packages_test.go +++ b/packages_test.go @@ -246,3 +246,51 @@ func TestSplitDependentsEmpty(t *testing.T) { t.Errorf("splitDependents(nil) = %v, %v; want empty", direct, indirect) } } + +func TestNewDependentsDoc(t *testing.T) { + docs := &PackageDocsResponse{Name: "JuMP", Version: "1.32.1"} + direct := []PackageDependent{{Direct: true, Name: "A", UUID: "u1"}} + indirect := []PackageDependent{{Name: "B", UUID: "u2"}, {Name: "C", UUID: "u3"}} + + got := newDependentsDoc(docs, "General", direct, indirect, false) + if got.Direct != 1 || got.Indirect != 2 || got.Total != 3 { + t.Errorf("counts = %d/%d/%d, want 1/2/3", got.Direct, got.Indirect, got.Total) + } + if len(got.Dependents) != 1 || got.Dependents[0].Name != "A" { + t.Errorf("without --indirect Dependents = %v, want only A", got.Dependents) + } + + got = newDependentsDoc(docs, "General", direct, indirect, true) + if len(got.Dependents) != 3 { + t.Errorf("with --indirect len(Dependents) = %d, want 3", len(got.Dependents)) + } + if got.Registry != "General" || got.Name != "JuMP" || got.Version != "1.32.1" { + t.Errorf("header = %q %q %q", got.Name, got.Version, got.Registry) + } +} + +func TestPackageJSONDocsEmptyArrays(t *testing.T) { + var buf strings.Builder + doc := newDependentsDoc(&PackageDocsResponse{Name: "X", Version: "1.0.0"}, "General", nil, nil, true) + if err := writeJSON(&buf, doc); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), `"dependents": []`) { + t.Errorf("dependents should encode as [], got:\n%s", buf.String()) + } + if !strings.HasSuffix(buf.String(), "}\n") { + t.Errorf("output should end with a newline:\n%q", buf.String()) + } + + buf.Reset() + if err := writePackagesJSON(&buf, nil, 0); err != nil { + t.Fatal(err) + } + var decoded struct { + Results []packageInfo `json:"results"` + Total int `json:"total"` + } + if err := json.Unmarshal([]byte(buf.String()), &decoded); err != nil || decoded.Results == nil || decoded.Total != 0 { + t.Errorf("empty info doc = %q (err %v), want results [] and total 0", buf.String(), err) + } +} From a98b091880aca27fe8e385902c17b19bf867a0c4 Mon Sep 17 00:00:00 2001 From: Panagiotis Georgakopoulos Date: Wed, 14 Oct 2026 19:49:08 +0300 Subject: [PATCH 2/4] feat: `jh package info` shows downloads, owner and the package's pkg.json details The REST listing already returns `owner`, `is_app`, `is_dyad` and `downloads_timeseries`, but `RESTPackage` dropped them (it decoded a `downloads.count` the API does not send). `restToInfo` now carries them, with `downloads_30d` as the sum of the 30 daily counts, so `jh package search --verbose` shows them too. `jh package info` also fetches the stable version's pkg.json for each match and prints its homepage, release date, dependency and dependent counts, contributors and docs build type. In `--json` these are under `details`, together with the daily download counts and the full contributor list. If pkg.json cannot be fetched, a warning goes to stderr and the listing fields are still printed. Backport of JuliaComputing/jh#70 (eb55899) to the 0.4 line. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + README.md | 2 +- packages.go | 201 ++++++++++++++++++++++++++++++++++++++++------- packages_test.go | 49 ++++++++++++ 4 files changed, 225 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 420f0fe..ef4011e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -515,6 +515,7 @@ jh run setup - 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 (`/api/v1/ui/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 +- `jh package info` adds `details` (`packageDetails`) to each match: the listing's `downloads_timeseries` plus the stable `pkg.json` (`fetchPackageDocs`, one request per match): homepage, release date, contributors, `doctype`/`documenter_errored`, and dependency/dependent counts (unique UUIDs, direct wins). A failed `pkg.json` fetch prints a warning on stderr and leaves those fields empty. `restToInfo` also carries `owner`, `is_app`, `is_dyad` and `downloads_30d` (sum of the 30 daily counts), so `jh package search --verbose` shows them too; the GraphQL fallback has no download data and omits `downloads_30d` - `jh package info --json` prints the exact matches in the same `{"results": [...], "total": N}` document (`writePackagesJSON`; no match → empty `results`). `jh package dependency --json` prints `dependenciesDoc` and `jh package dependents --json` prints `dependentsDoc` (counts always cover every dependent; `dependents` lists indirect ones only with `--indirect`). All go through `writeJSON`; errors from these three commands go to stderr - 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`, `packageInfoCmd`, and `packageDependencyCmd` to resolve registry names to IDs (for GraphQL) and names (for REST) diff --git a/README.md b/README.md index c12e519..3c11858 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ go build -o jh . - `--registries ` - Filter by registry names (comma-separated, e.g. `General,MyRegistry`) - `--limit ` - Maximum results to return (default: 10) - `--offset ` - Number of results to skip -- `jh package info ` - Get detailed information about a specific package (exact name match, case-insensitive) +- `jh package info ` - Get detailed information about a specific package (exact name match, case-insensitive): the listing fields (owner, stars, license, latest version, downloads over the last 30 days, app/Dyad type) plus the stable version's homepage, release date, dependency and dependent counts, contributors and docs build type - `jh package info --registries General` - Search in specific registries only - `jh package info --json` - Print only `{"results": [...], "total": N}` on stdout - `jh package dependency ` - List package dependencies diff --git a/packages.go b/packages.go index 12183cb..237359a 100644 --- a/packages.go +++ b/packages.go @@ -73,11 +73,23 @@ type PackageDependent struct { Slug string `json:"slug"` } +type PackageContributor struct { + Name string `json:"name"` + Contributions int `json:"contributions"` + Type string `json:"type"` + URL string `json:"url"` +} + type PackageDocsResponse struct { - Name string `json:"name"` - Version string `json:"version"` - Dependencies []PackageDependency `json:"deps"` - Dependents []PackageDependent `json:"reversedeps"` + Name string `json:"name"` + Version string `json:"version"` + Dependencies []PackageDependency `json:"deps"` + Dependents []PackageDependent `json:"reversedeps"` + Homepage string `json:"homepage"` + ReleaseDate string `json:"release_date"` + Contributors []PackageContributor `json:"contributors"` + DocType string `json:"doctype"` + DocumenterErrored bool `json:"documenter_errored"` } type Package struct { @@ -95,19 +107,26 @@ type Package struct { } type RESTPackage struct { - Name string `json:"name"` - UUID string `json:"uuid"` - Registry string `json:"registry"` - Description string `json:"description"` - StargazersCount int `json:"stargazers_count"` - SourceURL string `json:"source_url"` - JHubDocsURL string `json:"jhub_docs_url"` - LatestStableVersion string `json:"latest_stable_version"` - DetectedSourceLicenses []string `json:"detected_source_licenses"` - Downloads struct { - Count int `json:"count"` - } `json:"downloads"` - Tags []string `json:"tags"` + Name string `json:"name"` + UUID string `json:"uuid"` + Registry string `json:"registry"` + Description string `json:"description"` + StargazersCount int `json:"stargazers_count"` + SourceURL string `json:"source_url"` + JHubDocsURL string `json:"jhub_docs_url"` + LatestStableVersion string `json:"latest_stable_version"` + DetectedSourceLicenses []string `json:"detected_source_licenses"` + DownloadsTimeseries []DownloadCount `json:"downloads_timeseries"` + Tags []string `json:"tags"` + Owner string `json:"owner"` + IsApp bool `json:"is_app"` + IsDyad bool `json:"is_dyad"` +} + +// DownloadCount is one day of a package's download counts. +type DownloadCount struct { + Date string `json:"date"` + Count int `json:"count"` } type PackageRESTListResponse struct { @@ -141,8 +160,31 @@ type packageInfo struct { DocsURL string `json:"docs_url"` License string `json:"license"` IsApp bool `json:"is_app"` + IsDyad bool `json:"is_dyad"` Score float64 `json:"score"` Status string `json:"status"` + // Downloads30d is the sum of the REST listing's daily download counts, + // which cover the last 30 days; absent when the listing was not used. + Downloads30d *int `json:"downloads_30d,omitempty"` + DownloadsTimeseries []DownloadCount `json:"-"` + // Details is filled by `jh package info` only. + Details *packageDetails `json:"details,omitempty"` +} + +// packageDetails holds what `jh package info` adds to a listing entry: the +// daily download counts and the stable version's pkg.json metadata. +type packageDetails struct { + DownloadsTimeseries []DownloadCount `json:"downloads_timeseries"` + Version string `json:"version,omitempty"` + Homepage string `json:"homepage,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + Contributors []PackageContributor `json:"contributors"` + DocType string `json:"doc_type,omitempty"` + DocsBuildErrored bool `json:"docs_build_errored"` + DirectDependencies int `json:"direct_dependencies"` + Dependencies int `json:"dependencies"` + DirectDependents int `json:"direct_dependents"` + Dependents int `json:"dependents"` } // writeJSON writes doc 2-space indented with a trailing newline — the only @@ -228,9 +270,18 @@ func printPackages(pkgs []packageInfo, total int, verbose bool) { if pkg.IsApp { fmt.Printf("Type: Application\n") } + if pkg.IsDyad { + fmt.Printf("Type: Dyad library\n") + } + if pkg.Downloads30d != nil { + fmt.Printf("Downloads (last 30 days): %d\n", *pkg.Downloads30d) + } if pkg.Score != 0 { fmt.Printf("Score: %.2f\n", pkg.Score) } + if pkg.Details != nil { + printPackageDetails(pkg.Details) + } } else { fmt.Printf("%-30s %-20s %-20s", pkg.Name, pkg.Registry, pkg.Owner) if pkg.Version != "" { @@ -253,18 +304,67 @@ func printPackages(pkgs []packageInfo, total int, verbose bool) { // restToInfo converts a RESTPackage to the common packageInfo display struct. func restToInfo(p RESTPackage) packageInfo { + downloads := 0 + for _, d := range p.DownloadsTimeseries { + downloads += d.Count + } return packageInfo{ - Name: p.Name, - UUID: p.UUID, - Registry: p.Registry, - Version: p.LatestStableVersion, - Description: p.Description, - SourceURL: p.SourceURL, - Tags: p.Tags, - Stars: p.StargazersCount, - DocsURL: p.JHubDocsURL, - License: strings.Join(p.DetectedSourceLicenses, ", "), + Name: p.Name, + UUID: p.UUID, + Owner: p.Owner, + Registry: p.Registry, + Version: p.LatestStableVersion, + Description: p.Description, + SourceURL: p.SourceURL, + Tags: p.Tags, + Stars: p.StargazersCount, + DocsURL: p.JHubDocsURL, + License: strings.Join(p.DetectedSourceLicenses, ", "), + IsApp: p.IsApp, + IsDyad: p.IsDyad, + Downloads30d: &downloads, + DownloadsTimeseries: p.DownloadsTimeseries, + } +} + +// newPackageDetails builds the `jh package info` details from the listing's +// download counts and, when docs is non-nil, the package's pkg.json. A +// dependency or dependent listed both as direct and as indirect counts once, +// as direct. +func newPackageDetails(info packageInfo, docs *PackageDocsResponse) *packageDetails { + d := &packageDetails{ + DownloadsTimeseries: info.DownloadsTimeseries, + Contributors: []PackageContributor{}, + } + if d.DownloadsTimeseries == nil { + d.DownloadsTimeseries = []DownloadCount{} + } + if docs == nil { + return d + } + d.Version = docs.Version + d.Homepage = docs.Homepage + d.ReleaseDate = docs.ReleaseDate + if docs.Contributors != nil { + d.Contributors = docs.Contributors + } + d.DocType = docs.DocType + d.DocsBuildErrored = docs.DocumenterErrored + + depDirect := make(map[string]bool) + for _, dep := range docs.Dependencies { + depDirect[dep.UUID] = depDirect[dep.UUID] || dep.Direct + } + d.Dependencies = len(depDirect) + for _, direct := range depDirect { + if direct { + d.DirectDependencies++ + } } + direct, indirect := splitDependents(docs.Dependents) + d.DirectDependents = len(direct) + d.Dependents = len(direct) + len(indirect) + return d } // gqlToInfo converts a GraphQL Package to the common packageInfo display struct. @@ -646,6 +746,16 @@ func getPackageInfo(server, packageName string, registryIDs []int, registryNames return err } } + for i := range matches { + var docs *PackageDocsResponse + if matches[i].Registry != "" { + if docs, _, err = fetchPackageDocs(server, matches[i].Name, matches[i].Registry); err != nil { + fmt.Fprintf(os.Stderr, "warning: no package details for %s in %s: %v\n", matches[i].Name, matches[i].Registry, err) + docs = nil + } + } + matches[i].Details = newPackageDetails(matches[i], docs) + } if asJSON { return writePackagesJSON(os.Stdout, matches, len(matches)) } @@ -657,6 +767,43 @@ func getPackageInfo(server, packageName string, registryIDs []int, registryNames return nil } +// maxListedContributors caps the contributors `jh package info` names; --json +// lists all of them. +const maxListedContributors = 5 + +func printPackageDetails(d *packageDetails) { + if d.Homepage != "" { + fmt.Printf("Homepage: %s\n", d.Homepage) + } + if d.ReleaseDate != "" { + fmt.Printf("Released: %s\n", d.ReleaseDate) + } + if d.Dependencies > 0 { + fmt.Printf("Dependencies: %d direct, %d total\n", d.DirectDependencies, d.Dependencies) + } + if d.Dependents > 0 { + fmt.Printf("Dependents: %d direct, %d total\n", d.DirectDependents, d.Dependents) + } + if n := len(d.Contributors); n > 0 { + var names []string + for i := 0; i < n && i < maxListedContributors; i++ { + names = append(names, d.Contributors[i].Name) + } + more := "" + if n > maxListedContributors { + more = ", ..." + } + fmt.Printf("Contributors: %d (%s%s)\n", n, strings.Join(names, ", "), more) + } + if d.DocType != "" { + status := d.DocType + if d.DocsBuildErrored { + status += " (build errored)" + } + fmt.Printf("Docs: %s\n", status) + } +} + func findPackageInfoREST(server, packageName string, registryNames []string) ([]packageInfo, error) { pkgs, _, err := fetchRESTPackages(server, packageName, 100, 0, registryNames) if err != nil { diff --git a/packages_test.go b/packages_test.go index 06afbcf..0e15865 100644 --- a/packages_test.go +++ b/packages_test.go @@ -294,3 +294,52 @@ func TestPackageJSONDocsEmptyArrays(t *testing.T) { t.Errorf("empty info doc = %q (err %v), want results [] and total 0", buf.String(), err) } } + +func TestRestToInfoListingExtras(t *testing.T) { + var p RESTPackage + body := `{"name":"JuMP","owner":"jump-dev","is_app":false,"is_dyad":true, + "downloads_timeseries":[{"date":"2026-10-11","count":10},{"date":"2026-10-12","count":32}]}` + if err := json.Unmarshal([]byte(body), &p); err != nil { + t.Fatal(err) + } + got := restToInfo(p) + if got.Owner != "jump-dev" || !got.IsDyad || got.IsApp { + t.Errorf("Owner/IsDyad/IsApp = %q/%v/%v, want jump-dev/true/false", got.Owner, got.IsDyad, got.IsApp) + } + if got.Downloads30d == nil || *got.Downloads30d != 42 { + t.Errorf("Downloads30d = %v, want 42", got.Downloads30d) + } + if len(got.DownloadsTimeseries) != 2 { + t.Errorf("DownloadsTimeseries = %v, want 2 days", got.DownloadsTimeseries) + } +} + +func TestNewPackageDetails(t *testing.T) { + var docs PackageDocsResponse + body := `{"name":"JuMP","version":"1.32.1","homepage":"http://jump.dev","release_date":"Oct 2026", + "doctype":"documenter","documenter_errored":true, + "contributors":[{"name":"a","contributions":3,"type":"User","url":"u"}], + "deps":[{"direct":true,"uuid":"d1"},{"direct":false,"uuid":"d2"},{"direct":false,"uuid":"d1"}], + "reversedeps":[{"direct":true,"uuid":"r1"},{"direct":false,"uuid":"r1"},{"direct":false,"uuid":"r2"}]}` + if err := json.Unmarshal([]byte(body), &docs); err != nil { + t.Fatal(err) + } + got := newPackageDetails(packageInfo{}, &docs) + if got.DirectDependencies != 1 || got.Dependencies != 2 { + t.Errorf("dependencies = %d direct / %d total, want 1/2", got.DirectDependencies, got.Dependencies) + } + if got.DirectDependents != 1 || got.Dependents != 2 { + t.Errorf("dependents = %d direct / %d total, want 1/2", got.DirectDependents, got.Dependents) + } + if got.Homepage != "http://jump.dev" || got.ReleaseDate != "Oct 2026" || got.Version != "1.32.1" { + t.Errorf("metadata = %+v", got) + } + if !got.DocsBuildErrored || got.DocType != "documenter" || len(got.Contributors) != 1 { + t.Errorf("docs/contributors = %+v", got) + } + + empty := newPackageDetails(packageInfo{}, nil) + if empty.DownloadsTimeseries == nil || empty.Contributors == nil { + t.Errorf("without pkg.json, arrays must be non-nil: %+v", empty) + } +} From b2468a4433b7f190d3870f6f40fafddf109fdedb Mon Sep 17 00:00:00 2001 From: Panagiotis Georgakopoulos Date: Wed, 14 Oct 2026 19:59:49 +0300 Subject: [PATCH 3/4] feat: clearer text output for `jh package` commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `info` (and `search --verbose`) print a card per package: a header line `name vX · registry · owner`, the description, then aligned fields with thousands separators, a 30-day download sparkline, a link to the package's JuliaHub page and an install hint. - `search` prints an aligned table with STARS and DL/30 DAYS columns. Long names and descriptions are cut at rune boundaries with "…". - `dependency` and `dependents` print the same heading and direct/indirect/total counts, with standard libraries shown as `stdlib`. `dependency` lists each package once (`splitDependencies`, direct wins), in `--json` too. Bold and dim styling is used only when stdout is a terminal, `NO_COLOR` is unset and `TERM` is not `dumb`. The helpers live in `style.go`. Backport of JuliaComputing/jh#72 (c9368f8) to the 0.4 line; the 0.4 search path calls `printPackages` directly instead of through `emitPackages`. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 + packages.go | 425 ++++++++++++++++++++++++++++------------------- packages_test.go | 39 +++++ style.go | 115 +++++++++++++ style_test.go | 51 ++++++ 5 files changed, 462 insertions(+), 170 deletions(-) create mode 100644 style.go create mode 100644 style_test.go diff --git a/CLAUDE.md b/CLAUDE.md index ef4011e..0749a87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ The application follows a command-line interface pattern using the Cobra library - **landing.go**: Landing page management (show, update, remove) with REST API integration - **vuln.go**: Vulnerability scanning for Julia packages via REST API - **scan.go**: Trivy manifest scanning — discover and upload a Julia manifest (+ optional project file) and fetch results via the StaticAnalysis service. Manifest discovery recognizes every name Julia/Pkg accepts (`Manifest.toml`, `JuliaManifest.toml`, and version-specific `Manifest-v1.11.toml` / `JuliaManifest-v1.11.toml`); when a directory holds more than one, the user is prompted to select (defaults to the highest-precedence candidate). Project file is `Project.toml` or `JuliaProject.toml` +- **style.go**: terminal output helpers — `bold`/`dim` (ANSI only when stdout is a terminal, `NO_COLOR` is unset and `TERM` is not `dumb`; `colorEnabled`), `formatCount` (thousands separators), `sparkline`, rune-aware `truncate`/`pad`/`padLeft`, and `printField` (aligned `label value` lines). Text output is for people; scripts should use `--json` - **git.go**: Git integration (clone, push, fetch, pull) with JuliaHub authentication - **julia.go**: Julia installation and management - **run.go**: Julia execution with JuliaHub configuration @@ -516,6 +517,7 @@ jh run setup - 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 (`/api/v1/ui/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 - `jh package info` adds `details` (`packageDetails`) to each match: the listing's `downloads_timeseries` plus the stable `pkg.json` (`fetchPackageDocs`, one request per match): homepage, release date, contributors, `doctype`/`documenter_errored`, and dependency/dependent counts (unique UUIDs, direct wins). A failed `pkg.json` fetch prints a warning on stderr and leaves those fields empty. `restToInfo` also carries `owner`, `is_app`, `is_dyad` and `downloads_30d` (sum of the 30 daily counts), so `jh package search --verbose` shows them too; the GraphQL fallback has no download data and omits `downloads_30d` +- Package text output: `jh package info` and `search --verbose` print a card per package (`printPackageCard`: header `name vX · registry · owner`, description, aligned fields, links including the JuliaHub page `https:///ui/Packages//` from `packagePageURL`, install hint); `search` prints a table with STARS and DL/30 DAYS columns; `dependency` and `dependents` print `printPackageHeading`, the direct/indirect/total counts and tables, with standard libraries (no registry in pkg.json) shown as `stdlib`. `splitDependencies` collapses `deps` to one entry per UUID like `splitDependents`, for both text and `--json` - `jh package info --json` prints the exact matches in the same `{"results": [...], "total": N}` document (`writePackagesJSON`; no match → empty `results`). `jh package dependency --json` prints `dependenciesDoc` and `jh package dependents --json` prints `dependentsDoc` (counts always cover every dependent; `dependents` lists indirect ones only with `--indirect`). All go through `writeJSON`; errors from these three commands go to stderr - 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`, `packageInfoCmd`, and `packageDependencyCmd` to resolve registry names to IDs (for GraphQL) and names (for REST) diff --git a/packages.go b/packages.go index 237359a..5b08db8 100644 --- a/packages.go +++ b/packages.go @@ -12,6 +12,7 @@ import ( "sort" "strings" "time" + "unicode/utf8" ) //go:embed package_search.gql package_search_count.gql @@ -216,90 +217,191 @@ func writePackagesJSON(w io.Writer, pkgs []packageInfo, total int) error { return writeJSON(w, doc) } -func printPackages(pkgs []packageInfo, total int, verbose bool) { +func printPackages(server string, pkgs []packageInfo, total int, verbose bool) { if len(pkgs) == 0 { fmt.Println("No packages found") return } if total > len(pkgs) { - fmt.Printf("Showing %d of %d package(s):\n\n", len(pkgs), total) + fmt.Printf("Showing %s of %s packages\n\n", formatCount(len(pkgs)), formatCount(total)) } else { - fmt.Printf("Found %d package(s):\n\n", len(pkgs)) + fmt.Printf("Found %s %s\n\n", formatCount(len(pkgs)), plural(len(pkgs), "package", "packages")) } - if !verbose { - fmt.Printf("%-30s %-20s %-20s %-12s %s\n", "NAME", "REGISTRY", "OWNER", "VERSION", "DESCRIPTION") - fmt.Printf("%-30s %-20s %-20s %-12s %s\n", strings.Repeat("-", 30), strings.Repeat("-", 20), strings.Repeat("-", 30), strings.Repeat("-", 12), strings.Repeat("-", 50)) + if verbose { + printPackageCards(server, pkgs) + return } + printPackageTable(pkgs) +} + +const ( + nameColWidth = 28 + registryColWidth = 16 + ownerColWidth = 18 + versionColWidth = 11 + starsColWidth = 7 + downloadsColWidth = 11 + descColWidth = 50 +) +func printPackageTable(pkgs []packageInfo) { + header := pad("NAME", nameColWidth) + " " + pad("REGISTRY", registryColWidth) + " " + pad("OWNER", ownerColWidth) + " " + + pad("VERSION", versionColWidth) + " " + padLeft("STARS", starsColWidth) + " " + padLeft("DL/30 DAYS", downloadsColWidth) + " DESCRIPTION" + fmt.Println(bold(header)) for _, pkg := range pkgs { - if verbose { - fmt.Printf("Name: %s\n", pkg.Name) - fmt.Printf("UUID: %s\n", pkg.UUID) - if pkg.Registry != "" { - fmt.Printf("Registry: %s\n", pkg.Registry) - } - if pkg.Owner != "" { - fmt.Printf("Owner: %s\n", pkg.Owner) - } - if pkg.Description != "" { - fmt.Printf("Description: %s\n", pkg.Description) - } - if pkg.SourceURL != "" { - fmt.Printf("Repository: %s\n", pkg.SourceURL) - } - if len(pkg.Tags) > 0 { - fmt.Printf("Tags: %s\n", strings.Join(pkg.Tags, ", ")) - } - if pkg.Stars > 0 { - fmt.Printf("Stars: %d\n", pkg.Stars) - } - if pkg.DocsURL != "" { - fmt.Printf("Documentation: %s\n", pkg.DocsURL) - } - if pkg.License != "" { - fmt.Printf("License: %s\n", pkg.License) - } - if pkg.Version != "" { - fmt.Printf("Latest Version: %s\n", pkg.Version) - } - if pkg.Status != "" { - fmt.Printf("Status: %s\n", pkg.Status) - } - if pkg.IsApp { - fmt.Printf("Type: Application\n") - } - if pkg.IsDyad { - fmt.Printf("Type: Dyad library\n") - } - if pkg.Downloads30d != nil { - fmt.Printf("Downloads (last 30 days): %d\n", *pkg.Downloads30d) - } - if pkg.Score != 0 { - fmt.Printf("Score: %.2f\n", pkg.Score) - } - if pkg.Details != nil { - printPackageDetails(pkg.Details) + version := "-" + if pkg.Version != "" { + version = "v" + pkg.Version + } + downloads := "-" + if pkg.Downloads30d != nil { + downloads = formatCount(*pkg.Downloads30d) + } + fmt.Printf("%s %s %s %s %s %s %s\n", + bold(pad(truncate(pkg.Name, nameColWidth), nameColWidth)), + pad(truncate(pkg.Registry, registryColWidth), registryColWidth), + pad(truncate(pkg.Owner, ownerColWidth), ownerColWidth), + pad(truncate(version, versionColWidth), versionColWidth), + padLeft(formatCount(pkg.Stars), starsColWidth), + padLeft(downloads, downloadsColWidth), + dim(truncate(pkg.Description, descColWidth))) + } +} + +// maxListedContributors caps the contributors a package card names and +// maxTagsWidth the runes of its tag list; --json lists all of them. +const ( + maxListedContributors = 5 + maxTagsWidth = 64 +) + +func printPackageCards(server string, pkgs []packageInfo) { + for i, pkg := range pkgs { + if i > 0 { + fmt.Println() + } + printPackageCard(server, pkg) + } +} + +// printPackageCard prints one package: a header line, the description, then +// aligned fields grouped as numbers, links and install hint. Fields the +// package lacks are skipped. +func printPackageCard(server string, pkg packageInfo) { + header := []string{} + if pkg.Version != "" { + header = append(header, "v"+pkg.Version) + } + for _, part := range []string{pkg.Registry, pkg.Owner} { + if part != "" { + header = append(header, part) + } + } + fmt.Printf("%s %s\n", bold(pkg.Name), dim(strings.Join(header, " · "))) + if pkg.Description != "" { + fmt.Println(pkg.Description) + } + fmt.Println() + + d := pkg.Details + printField("Stars", formatCount(pkg.Stars)) + if pkg.Downloads30d != nil { + downloads := formatCount(*pkg.Downloads30d) + " in the last 30 days" + if d != nil && len(d.DownloadsTimeseries) > 1 { + counts := make([]int, len(d.DownloadsTimeseries)) + for i, c := range d.DownloadsTimeseries { + counts[i] = c.Count } - } else { - fmt.Printf("%-30s %-20s %-20s", pkg.Name, pkg.Registry, pkg.Owner) - if pkg.Version != "" { - fmt.Printf(" v%-10s", pkg.Version) - } else { - fmt.Printf(" %-12s", "N/A") + downloads += " " + sparkline(counts) + } + printField("Downloads", downloads) + } + if d != nil { + if d.Dependencies > 0 { + printField("Dependencies", fmt.Sprintf("%s direct · %s total", formatCount(d.DirectDependencies), formatCount(d.Dependencies))) + } + if d.Dependents > 0 { + printField("Dependents", fmt.Sprintf("%s direct · %s total", formatCount(d.DirectDependents), formatCount(d.Dependents))) + } + if n := len(d.Contributors); n > 0 { + var names []string + for i := 0; i < n && i < maxListedContributors; i++ { + names = append(names, d.Contributors[i].Name) } - if pkg.Description != "" { - desc := pkg.Description - if len(desc) > 50 { - desc = desc[:50] + "..." - } - fmt.Printf("%s", desc) + if n > maxListedContributors { + names = append(names, "…") } - fmt.Printf("\n") + printField("Contributors", formatCount(n)+" · "+strings.Join(names, ", ")) } - fmt.Println() } + printField("License", pkg.License) + if d != nil { + printField("Released", d.ReleaseDate) + docs := d.DocType + if docs != "" && d.DocsBuildErrored { + docs += " (build errored)" + } + printField("Docs build", docs) + } + var kinds []string + if pkg.IsApp { + kinds = append(kinds, "Application") + } + if pkg.IsDyad { + kinds = append(kinds, "Dyad library") + } + printField("Type", strings.Join(kinds, ", ")) + printField("Tags", listTags(pkg.Tags)) + printField("Status", pkg.Status) + if pkg.Score != 0 { + printField("Score", fmt.Sprintf("%.2f", pkg.Score)) + } + + fmt.Println() + printField("Repository", strings.TrimSuffix(pkg.SourceURL, ".git")) + if d != nil { + printField("Homepage", d.Homepage) + } + printField("Documentation", pkg.DocsURL) + if server != "" && pkg.Registry != "" { + printField("JuliaHub", packagePageURL(server, pkg.Registry, pkg.Name)) + } + printField("UUID", pkg.UUID) + + fmt.Println() + printField("Install", "] add "+pkg.Name) +} + +// listTags joins as many tags as fit in maxTagsWidth runes (at least one) +// and counts the rest. +func listTags(tags []string) string { + shown, width := 0, 0 + for _, t := range tags { + w := utf8.RuneCountInString(t) + 2 + if shown > 0 && width+w > maxTagsWidth { + break + } + shown, width = shown+1, width+w + } + out := strings.Join(tags[:shown], ", ") + if extra := len(tags) - shown; extra > 0 { + out += fmt.Sprintf(", … (+%d)", extra) + } + return out +} + +// packagePageURL is the package's page in the JuliaHub web UI. +func packagePageURL(server, registry, name string) string { + return fmt.Sprintf("https://%s/ui/Packages/%s/%s", server, url.PathEscape(registry), url.PathEscape(name)) +} + +func plural(n int, one, many string) string { + if n == 1 { + return one + } + return many } // restToInfo converts a RESTPackage to the common packageInfo display struct. @@ -671,7 +773,7 @@ func searchPackagesREST(params PackageSearchParams) error { for i, p := range pkgs { infos[i] = restToInfo(p) } - printPackages(infos, total, params.Verbose) + printPackages(params.Server, infos, total, params.Verbose) return nil } @@ -689,7 +791,7 @@ func searchPackagesGraphQL(params PackageSearchParams) error { for i, p := range pkgs { infos[i] = gqlToInfo(p, registryIDToName) } - printPackages(infos, total, params.Verbose) + printPackages(params.Server, infos, total, params.Verbose) return nil } @@ -763,45 +865,12 @@ func getPackageInfo(server, packageName string, registryIDs []int, registryNames fmt.Println("Package not found") return nil } - printPackages(matches, len(matches), true) - return nil -} - -// maxListedContributors caps the contributors `jh package info` names; --json -// lists all of them. -const maxListedContributors = 5 - -func printPackageDetails(d *packageDetails) { - if d.Homepage != "" { - fmt.Printf("Homepage: %s\n", d.Homepage) - } - if d.ReleaseDate != "" { - fmt.Printf("Released: %s\n", d.ReleaseDate) - } - if d.Dependencies > 0 { - fmt.Printf("Dependencies: %d direct, %d total\n", d.DirectDependencies, d.Dependencies) - } - if d.Dependents > 0 { - fmt.Printf("Dependents: %d direct, %d total\n", d.DirectDependents, d.Dependents) - } - if n := len(d.Contributors); n > 0 { - var names []string - for i := 0; i < n && i < maxListedContributors; i++ { - names = append(names, d.Contributors[i].Name) - } - more := "" - if n > maxListedContributors { - more = ", ..." - } - fmt.Printf("Contributors: %d (%s%s)\n", n, strings.Join(names, ", "), more) - } - if d.DocType != "" { - status := d.DocType - if d.DocsBuildErrored { - status += " (build errored)" - } - fmt.Printf("Docs: %s\n", status) + if len(matches) == 1 { + printPackageCard(server, matches[0]) + return nil } + printPackages(server, matches, len(matches), true) + return nil } func findPackageInfoREST(server, packageName string, registryNames []string) ([]packageInfo, error) { @@ -935,72 +1004,90 @@ func getPackageDependencies(server string, packageName string, registryName stri return err } - var deps []PackageDependency + direct, indirect := splitDependencies(docsResp.Dependencies) + deps := append([]PackageDependency{}, direct...) if showIndirect { - deps = docsResp.Dependencies - } else { - for _, dep := range docsResp.Dependencies { - if dep.Direct { - deps = append(deps, dep) - } - } + deps = append(deps, indirect...) } if asJSON { - if deps == nil { - deps = []PackageDependency{} - } return writeJSON(os.Stdout, dependenciesDoc{docsResp.Name, docsResp.Version, targetRegistry, deps}) } - 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) - } + printPackageHeading(docsResp, targetRegistry) + if len(direct)+len(indirect) == 0 { + fmt.Println("No dependencies") return nil } + fmt.Printf("%s direct · %s indirect · %s total dependencies\n", + formatCount(len(direct)), formatCount(len(indirect)), formatCount(len(direct)+len(indirect))) + if len(direct) > 0 { + fmt.Println() + printDependencyTable("Direct dependencies", direct) + } + if showIndirect && len(indirect) > 0 { + fmt.Println() + printDependencyTable("Indirect dependencies", indirect) + } else if len(indirect) > 0 { + fmt.Printf("\n%s\n", dim(fmt.Sprintf("Use --indirect to list the %s indirect dependencies", formatCount(len(indirect))))) + } + 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) - } +// printPackageHeading prints " v · " and a blank line. +func printPackageHeading(docsResp *PackageDocsResponse, registry string) { + fmt.Printf("%s %s\n\n", bold(docsResp.Name), dim("v"+docsResp.Version+" · "+registry)) +} + +// splitDependencies collapses entries to one per UUID, sorted by name. A +// dependency listed as direct in any entry is direct and keeps that entry's +// versions; the rest are indirect. +func splitDependencies(entries []PackageDependency) (direct, indirect []PackageDependency) { + byUUID := make(map[string]PackageDependency) + for _, e := range entries { + if prev, ok := byUUID[e.UUID]; ok && (prev.Direct || !e.Direct) { + continue } - 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() + byUUID[e.UUID] = e + } + for _, e := range byUUID { + if e.Direct { + direct = append(direct, e) + } else { + indirect = append(indirect, e) } - 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, ", ")) + } + byName := func(s []PackageDependency) { + sort.Slice(s, func(i, j int) bool { + if a, b := strings.ToLower(s[i].Name), strings.ToLower(s[j].Name); a != b { + return a < b } - } + return s[i].UUID < s[j].UUID + }) } + byName(direct) + byName(indirect) + return direct, indirect +} - return nil +const uuidColWidth = 36 + +// registryLabel shows standard libraries, which pkg.json lists without a +// registry, as "stdlib". +func registryLabel(registry string) string { + if registry == "" { + return dim(pad("stdlib", registryColWidth)) + } + return pad(truncate(registry, registryColWidth), registryColWidth) +} + +func printDependencyTable(title string, deps []PackageDependency) { + fmt.Printf("%s %s\n", bold(title), dim("("+formatCount(len(deps))+")")) + fmt.Println(bold(pad("NAME", nameColWidth+7) + " " + pad("REGISTRY", registryColWidth) + " " + pad("UUID", uuidColWidth) + " VERSIONS")) + for _, dep := range deps { + fmt.Printf("%s %s %s %s\n", pad(truncate(dep.Name, nameColWidth+7), nameColWidth+7), registryLabel(dep.Registry), + dim(pad(dep.UUID, uuidColWidth)), strings.Join(dep.Versions, ", ")) + } } // splitDependents collapses entries to one per UUID, sorted by name. A @@ -1034,11 +1121,10 @@ func splitDependents(entries []PackageDependent) (direct, indirect []PackageDepe } func printDependentsTable(title string, deps []PackageDependent) { - fmt.Printf("%s (%d):\n", title, len(deps)) - fmt.Printf("%-35s %-15s %s\n", "NAME", "REGISTRY", "UUID") - fmt.Printf("%-35s %-15s %s\n", strings.Repeat("-", 35), strings.Repeat("-", 15), strings.Repeat("-", 36)) + fmt.Printf("%s %s\n", bold(title), dim("("+formatCount(len(deps))+")")) + fmt.Println(bold(pad("NAME", nameColWidth+7) + " " + pad("REGISTRY", registryColWidth) + " UUID")) for _, d := range deps { - fmt.Printf("%-35s %-15s %s\n", d.Name, d.Registry, d.UUID) + fmt.Printf("%s %s %s\n", pad(truncate(d.Name, nameColWidth+7), nameColWidth+7), registryLabel(d.Registry), dim(d.UUID)) } } @@ -1081,23 +1167,22 @@ func getPackageDependents(server, packageName, registryName string, showIndirect if asJSON { return writeJSON(os.Stdout, newDependentsDoc(docsResp, targetRegistry, direct, indirect, showIndirect)) } + printPackageHeading(docsResp, targetRegistry) if len(direct)+len(indirect) == 0 { - fmt.Printf("Package %s (v%s) has no dependents\n", docsResp.Name, docsResp.Version) + fmt.Println("No dependents") return nil } - - fmt.Printf("Dependents of %s (v%s) from registry '%s':\n\n", docsResp.Name, docsResp.Version, targetRegistry) - fmt.Printf("%d direct, %d indirect, %d total\n\n", len(direct), len(indirect), len(direct)+len(indirect)) + fmt.Printf("%s direct · %s indirect · %s total dependents\n", + formatCount(len(direct)), formatCount(len(indirect)), formatCount(len(direct)+len(indirect))) if len(direct) > 0 { + fmt.Println() printDependentsTable("Direct dependents", direct) } if showIndirect && len(indirect) > 0 { - if len(direct) > 0 { - fmt.Println() - } + fmt.Println() printDependentsTable("Indirect dependents", indirect) } else if len(indirect) > 0 { - fmt.Printf("\nUse --indirect to list the %d indirect dependents\n", len(indirect)) + fmt.Printf("\n%s\n", dim(fmt.Sprintf("Use --indirect to list the %s indirect dependents", formatCount(len(indirect))))) } return nil } diff --git a/packages_test.go b/packages_test.go index 0e15865..5bb3fba 100644 --- a/packages_test.go +++ b/packages_test.go @@ -343,3 +343,42 @@ func TestNewPackageDetails(t *testing.T) { t.Errorf("without pkg.json, arrays must be non-nil: %+v", empty) } } + +func TestSplitDependencies(t *testing.T) { + entries := []PackageDependency{ + {Direct: false, Name: "Zed", UUID: "u1", Versions: []string{"2"}}, + {Direct: true, Name: "Zed", UUID: "u1", Versions: []string{"1"}}, + {Direct: false, Name: "beta", UUID: "u2"}, + {Direct: false, Name: "beta", UUID: "u2"}, + {Direct: true, Name: "Alpha", UUID: "u3"}, + } + direct, indirect := splitDependencies(entries) + if len(direct) != 2 || direct[0].Name != "Alpha" || direct[1].Name != "Zed" { + t.Fatalf("direct = %+v, want Alpha, Zed", direct) + } + if !reflect.DeepEqual(direct[1].Versions, []string{"1"}) { + t.Errorf("Zed keeps its direct entry's versions, got %v", direct[1].Versions) + } + if len(indirect) != 1 || indirect[0].Name != "beta" { + t.Errorf("indirect = %+v, want beta once", indirect) + } +} + +func TestPackagePageURL(t *testing.T) { + if got := packagePageURL("juliahub.com", "General", "JuMP"); got != "https://juliahub.com/ui/Packages/General/JuMP" { + t.Errorf("packagePageURL = %q", got) + } +} + +func TestListTags(t *testing.T) { + if got := listTags(nil); got != "" { + t.Errorf("listTags(nil) = %q", got) + } + if got := listTags([]string{"a", "b"}); got != "a, b" { + t.Errorf("listTags short = %q", got) + } + long := strings.Repeat("x", 70) + if got := listTags([]string{long, "b", "c"}); got != long+", … (+2)" { + t.Errorf("listTags keeps at least one tag: %q", got) + } +} diff --git a/style.go b/style.go new file mode 100644 index 0000000..da5d529 --- /dev/null +++ b/style.go @@ -0,0 +1,115 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "unicode/utf8" +) + +// colorEnabled is true when stdout is a terminal, NO_COLOR is unset and TERM +// is not "dumb"; piped or redirected output stays plain text. +var colorEnabled = detectColor() + +func detectColor() bool { + if _, ok := os.LookupEnv("NO_COLOR"); ok || os.Getenv("TERM") == "dumb" { + return false + } + info, err := os.Stdout.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + +func ansi(code, s string) string { + if !colorEnabled || s == "" { + return s + } + return "\033[" + code + "m" + s + "\033[0m" +} + +func bold(s string) string { return ansi("1", s) } +func dim(s string) string { return ansi("2", s) } + +// formatCount renders n with comma thousands separators: 78428 → "78,428". +func formatCount(n int) string { + s := strconv.Itoa(n) + neg := strings.HasPrefix(s, "-") + if neg { + s = s[1:] + } + var b strings.Builder + for i, r := range s { + if i > 0 && (len(s)-i)%3 == 0 { + b.WriteByte(',') + } + b.WriteRune(r) + } + if neg { + return "-" + b.String() + } + return b.String() +} + +var sparkBlocks = []rune("▁▂▃▄▅▆▇█") + +// sparkline maps values onto eight block heights between their minimum and +// maximum; a flat series renders as the lowest block. +func sparkline(values []int) string { + if len(values) == 0 { + return "" + } + lo, hi := values[0], values[0] + for _, v := range values { + lo, hi = min(lo, v), max(hi, v) + } + var b strings.Builder + for _, v := range values { + i := 0 + if hi > lo { + i = (v - lo) * (len(sparkBlocks) - 1) / (hi - lo) + } + b.WriteRune(sparkBlocks[i]) + } + return b.String() +} + +// truncate shortens s to at most width runes, ending in "…" when cut. +func truncate(s string, width int) string { + if utf8.RuneCountInString(s) <= width { + return s + } + if width <= 1 { + return "…" + } + return string([]rune(s)[:width-1]) + "…" +} + +// pad left-aligns s in width runes; ANSI styling is applied after padding so +// escape codes never count towards the width. +func pad(s string, width int) string { + if n := utf8.RuneCountInString(s); n < width { + return s + strings.Repeat(" ", width-n) + } + return s +} + +func padLeft(s string, width int) string { + if n := utf8.RuneCountInString(s); n < width { + return strings.Repeat(" ", width-n) + s + } + return s +} + +const fieldLabelWidth = 14 + +// printField prints an indented, aligned "label value" line; empty values +// are skipped. +func printField(label, value string) { + if value == "" { + return + } + fmt.Printf(" %s%s\n", dim(pad(label, fieldLabelWidth)), value) +} diff --git a/style_test.go b/style_test.go new file mode 100644 index 0000000..5886c58 --- /dev/null +++ b/style_test.go @@ -0,0 +1,51 @@ +package main + +import "testing" + +func TestFormatCount(t *testing.T) { + for n, want := range map[int]string{0: "0", 7: "7", 999: "999", 1000: "1,000", 78428: "78,428", 1234567: "1,234,567", -4200: "-4,200"} { + if got := formatCount(n); got != want { + t.Errorf("formatCount(%d) = %q, want %q", n, got, want) + } + } +} + +func TestSparkline(t *testing.T) { + if got := sparkline([]int{0, 7, 14}); got != "▁▄█" { + t.Errorf("sparkline = %q, want ▁▄█", got) + } + if got := sparkline([]int{5, 5}); got != "▁▁" { + t.Errorf("flat sparkline = %q, want ▁▁", got) + } + if got := sparkline(nil); got != "" { + t.Errorf("empty sparkline = %q", got) + } +} + +func TestTruncateAndPad(t *testing.T) { + if got := truncate("Modélisation", 6); got != "Modél…" { + t.Errorf("truncate = %q, want Modél…", got) + } + if got := truncate("JuMP", 10); got != "JuMP" { + t.Errorf("truncate short = %q", got) + } + if got := pad("é", 3); got != "é " { + t.Errorf("pad counts runes: %q", got) + } + if got := padLeft("42", 4); got != " 42" { + t.Errorf("padLeft = %q", got) + } +} + +func TestStylingOffWhenDisabled(t *testing.T) { + prev := colorEnabled + colorEnabled = false + defer func() { colorEnabled = prev }() + if bold("x") != "x" || dim("y") != "y" { + t.Error("styling must be a no-op when color is disabled") + } + colorEnabled = true + if bold("x") != "\033[1mx\033[0m" { + t.Errorf("bold = %q", bold("x")) + } +} From a0126e18a64c2d488d8669173f72ea73a5ab262b Mon Sep 17 00:00:00 2001 From: Panagiotis Georgakopoulos Date: Wed, 14 Oct 2026 20:14:17 +0300 Subject: [PATCH 4/4] feat: `--short` for `jh package dependency` and `dependents` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prints a single line with the counts, e.g. JuMP v1.32.1 · General: 9 direct · 40 indirect · 49 total dependencies `--short` cannot be combined with `--json`. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 2 ++ main.go | 14 ++++++++++---- packages.go | 32 ++++++++++++++++++++++++++------ packages_test.go | 9 +++++++++ 5 files changed, 48 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0749a87..bb2db07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -517,7 +517,7 @@ jh run setup - 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 (`/api/v1/ui/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 - `jh package info` adds `details` (`packageDetails`) to each match: the listing's `downloads_timeseries` plus the stable `pkg.json` (`fetchPackageDocs`, one request per match): homepage, release date, contributors, `doctype`/`documenter_errored`, and dependency/dependent counts (unique UUIDs, direct wins). A failed `pkg.json` fetch prints a warning on stderr and leaves those fields empty. `restToInfo` also carries `owner`, `is_app`, `is_dyad` and `downloads_30d` (sum of the 30 daily counts), so `jh package search --verbose` shows them too; the GraphQL fallback has no download data and omits `downloads_30d` -- Package text output: `jh package info` and `search --verbose` print a card per package (`printPackageCard`: header `name vX · registry · owner`, description, aligned fields, links including the JuliaHub page `https:///ui/Packages//` from `packagePageURL`, install hint); `search` prints a table with STARS and DL/30 DAYS columns; `dependency` and `dependents` print `printPackageHeading`, the direct/indirect/total counts and tables, with standard libraries (no registry in pkg.json) shown as `stdlib`. `splitDependencies` collapses `deps` to one entry per UUID like `splitDependents`, for both text and `--json` +- Package text output: `jh package info` and `search --verbose` print a card per package (`printPackageCard`: header `name vX · registry · owner`, description, aligned fields, links including the JuliaHub page `https:///ui/Packages//` from `packagePageURL`, install hint); `search` prints a table with STARS and DL/30 DAYS columns; `dependency` and `dependents` print `printPackageHeading`, the direct/indirect/total counts and tables, with standard libraries (no registry in pkg.json) shown as `stdlib`. `splitDependencies` collapses `deps` to one entry per UUID like `splitDependents`, for both text and `--json`. `--short` on `dependency`/`dependents` prints only `printPackageSummary` (`name vX · registry: `); it is mutually exclusive with `--json` - `jh package info --json` prints the exact matches in the same `{"results": [...], "total": N}` document (`writePackagesJSON`; no match → empty `results`). `jh package dependency --json` prints `dependenciesDoc` and `jh package dependents --json` prints `dependentsDoc` (counts always cover every dependent; `dependents` lists indirect ones only with `--indirect`). All go through `writeJSON`; errors from these three commands go to stderr - 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`, `packageInfoCmd`, and `packageDependencyCmd` to resolve registry names to IDs (for GraphQL) and names (for REST) diff --git a/README.md b/README.md index 3c11858..f648981 100644 --- a/README.md +++ b/README.md @@ -169,11 +169,13 @@ go build -o jh . - 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 + - `jh package dependency --short` - Print one line: `JuMP v1.32.1 · General: 9 direct · 40 indirect · 49 total dependencies` - `jh package dependency --json` - Print only `{"name", "version", "registry", "dependencies": [...]}` on stdout (`--indirect` adds the indirect ones) - `jh package dependents ` - List packages that depend on a package (reverse dependencies) - Default: Prints direct/indirect/total counts and lists the direct dependents (NAME, REGISTRY, UUID) - `jh package dependents --indirect` - Also list indirect dependents - `jh package dependents --registry General` - Specify registry to use + - `jh package dependents --short` - Print one line: `JuMP v1.32.1 · General: 335 direct · 94 indirect · 429 total dependents` - `jh package dependents --json` - Print only `{"name", "version", "registry", "direct", "indirect", "total", "dependents": [...]}` on stdout; the counts always cover every dependent, `dependents` lists the indirect ones only with `--indirect` ### Registry Management (`jh registry`) diff --git a/main.go b/main.go index c1ac174..73b8e6d 100644 --- a/main.go +++ b/main.go @@ -1077,7 +1077,7 @@ 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\n jh package dependency --json JuMP", + Example: " jh package dependency DataFrames\n jh package dependency --indirect Plots\n jh package dependency --registry General CSV\n jh package dependency --short JuMP\n jh package dependency --json JuMP", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { server, err := getServerFromFlagOrConfig(cmd) @@ -1088,10 +1088,11 @@ the --registry flag.`, packageName := args[0] asJSON, _ := cmd.Flags().GetBool("json") + short, _ := cmd.Flags().GetBool("short") registryName, _ := cmd.Flags().GetString("registry") showIndirect, _ := cmd.Flags().GetBool("indirect") - if err := getPackageDependencies(server, packageName, registryName, showIndirect, asJSON); err != nil { + if err := getPackageDependencies(server, packageName, registryName, showIndirect, asJSON, short); err != nil { fmt.Fprintf(os.Stderr, "Failed to get package dependencies: %v\n", err) os.Exit(1) } @@ -1111,7 +1112,7 @@ The data comes from the same package documentation JSON endpoint as jh package dependency, for the package's stable version. 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 dependents JuMP\n jh package dependents --indirect DataFrames\n jh package dependents --registry General CSV\n jh package dependents --json JuMP", + Example: " jh package dependents JuMP\n jh package dependents --indirect DataFrames\n jh package dependents --registry General CSV\n jh package dependents --short JuMP\n jh package dependents --json JuMP", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { server, err := getServerFromFlagOrConfig(cmd) @@ -1122,10 +1123,11 @@ You can specify a different registry using the --registry flag.`, packageName := args[0] asJSON, _ := cmd.Flags().GetBool("json") + short, _ := cmd.Flags().GetBool("short") registryName, _ := cmd.Flags().GetString("registry") showIndirect, _ := cmd.Flags().GetBool("indirect") - if err := getPackageDependents(server, packageName, registryName, showIndirect, asJSON); err != nil { + if err := getPackageDependents(server, packageName, registryName, showIndirect, asJSON, short); err != nil { fmt.Fprintf(os.Stderr, "Failed to get package dependents: %v\n", err) os.Exit(1) } @@ -2496,10 +2498,14 @@ func init() { packageDependencyCmd.Flags().String("registry", "", "Specify registry name (uses first registry if not specified)") packageDependencyCmd.Flags().Bool("indirect", false, "Include indirect dependencies") packageDependencyCmd.Flags().Bool("json", false, "Print only a JSON document ({\"name\", \"version\", \"registry\", \"dependencies\": [...]}) on stdout") + packageDependencyCmd.Flags().Bool("short", false, "Print only one line: the direct, indirect and total dependencies counts") + packageDependencyCmd.MarkFlagsMutuallyExclusive("short", "json") packageDependentsCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") packageDependentsCmd.Flags().String("registry", "", "Specify registry name (uses first registry if not specified)") packageDependentsCmd.Flags().Bool("indirect", false, "Also list indirect dependents") packageDependentsCmd.Flags().Bool("json", false, "Print only a JSON document ({\"name\", \"version\", \"registry\", \"direct\", \"indirect\", \"total\", \"dependents\": [...]}) on stdout") + packageDependentsCmd.Flags().Bool("short", false, "Print only one line: the direct, indirect and total dependents counts") + packageDependentsCmd.MarkFlagsMutuallyExclusive("short", "json") 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") diff --git a/packages.go b/packages.go index 5b08db8..82f1d4a 100644 --- a/packages.go +++ b/packages.go @@ -998,7 +998,7 @@ type dependenciesDoc struct { Dependencies []PackageDependency `json:"dependencies"` } -func getPackageDependencies(server string, packageName string, registryName string, showIndirect, asJSON bool) error { +func getPackageDependencies(server string, packageName string, registryName string, showIndirect, asJSON, short bool) error { docsResp, targetRegistry, err := fetchPackageDocs(server, packageName, registryName) if err != nil { return err @@ -1014,13 +1014,17 @@ func getPackageDependencies(server string, packageName string, registryName stri return writeJSON(os.Stdout, dependenciesDoc{docsResp.Name, docsResp.Version, targetRegistry, deps}) } + summary := countsSummary(len(direct), len(indirect), "dependencies") + if short { + printPackageSummary(docsResp, targetRegistry, summary) + return nil + } printPackageHeading(docsResp, targetRegistry) if len(direct)+len(indirect) == 0 { fmt.Println("No dependencies") return nil } - fmt.Printf("%s direct · %s indirect · %s total dependencies\n", - formatCount(len(direct)), formatCount(len(indirect)), formatCount(len(direct)+len(indirect))) + fmt.Println(summary) if len(direct) > 0 { fmt.Println() printDependencyTable("Direct dependencies", direct) @@ -1034,6 +1038,18 @@ func getPackageDependencies(server string, packageName string, registryName stri return nil } +// countsSummary renders "9 direct · 40 indirect · 49 total ". +func countsSummary(direct, indirect int, noun string) string { + return fmt.Sprintf("%s direct · %s indirect · %s total %s", + formatCount(direct), formatCount(indirect), formatCount(direct+indirect), noun) +} + +// printPackageSummary prints the one-line --short form: +// " v · : ". +func printPackageSummary(docsResp *PackageDocsResponse, registry, summary string) { + fmt.Printf("%s %s %s\n", bold(docsResp.Name), dim("v"+docsResp.Version+" · "+registry+":"), summary) +} + // printPackageHeading prints " v · " and a blank line. func printPackageHeading(docsResp *PackageDocsResponse, registry string) { fmt.Printf("%s %s\n\n", bold(docsResp.Name), dim("v"+docsResp.Version+" · "+registry)) @@ -1157,7 +1173,7 @@ func newDependentsDoc(docsResp *PackageDocsResponse, registry string, direct, in } } -func getPackageDependents(server, packageName, registryName string, showIndirect, asJSON bool) error { +func getPackageDependents(server, packageName, registryName string, showIndirect, asJSON, short bool) error { docsResp, targetRegistry, err := fetchPackageDocs(server, packageName, registryName) if err != nil { return err @@ -1167,13 +1183,17 @@ func getPackageDependents(server, packageName, registryName string, showIndirect if asJSON { return writeJSON(os.Stdout, newDependentsDoc(docsResp, targetRegistry, direct, indirect, showIndirect)) } + summary := countsSummary(len(direct), len(indirect), "dependents") + if short { + printPackageSummary(docsResp, targetRegistry, summary) + return nil + } printPackageHeading(docsResp, targetRegistry) if len(direct)+len(indirect) == 0 { fmt.Println("No dependents") return nil } - fmt.Printf("%s direct · %s indirect · %s total dependents\n", - formatCount(len(direct)), formatCount(len(indirect)), formatCount(len(direct)+len(indirect))) + fmt.Println(summary) if len(direct) > 0 { fmt.Println() printDependentsTable("Direct dependents", direct) diff --git a/packages_test.go b/packages_test.go index 5bb3fba..d531c77 100644 --- a/packages_test.go +++ b/packages_test.go @@ -382,3 +382,12 @@ func TestListTags(t *testing.T) { t.Errorf("listTags keeps at least one tag: %q", got) } } + +func TestCountsSummary(t *testing.T) { + if got := countsSummary(9, 40, "dependencies"); got != "9 direct · 40 indirect · 49 total dependencies" { + t.Errorf("countsSummary = %q", got) + } + if got := countsSummary(1335, 0, "dependents"); got != "1,335 direct · 0 indirect · 1,335 total dependents" { + t.Errorf("countsSummary = %q", got) + } +}