From eb55899e50ef93190a7380e3028ecc2788203040 Mon Sep 17 00:00:00 2001 From: Panagiotis Georgakopoulos Date: Wed, 14 Oct 2026 19:49:08 +0300 Subject: [PATCH 1/3] 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`/`--json` show 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. 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 450b952..1e9dc34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -533,6 +533,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; `searchPackages` prints `warning: REST package search failed (...); falling back to GraphQL` to stderr when it falls back (`getPackageInfo` falls back silently) - `--registries` resolution for `jh package search` / `jh package info` / `jh search packages` goes through `resolveRegistries(server, csv)` in `registries.go` (empty = all registries; unknown name → `Registry not found: ''`) +- `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 --json` gains 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 - `jh package search` / `jh search packages --json` sets `PackageSearchParams.JSON`; both the REST and GraphQL paths then call `writePackagesJSON`, which prints `{"results": [...], "total": N}` (snake_case keys from `packageInfo`'s json tags, `tags` always an array) 2-space indented with a trailing newline — nothing else on stdout - **JSON output convention** (`jh search *`): `--json` prints only the document on stdout, 2-space indented, trailing newline — `{"results": , "truncated": bool}` for code/symbols/docs; warnings, the human-mode `note: results truncated (search stopped early)` line, and errors always go to stderr; exit code is 0/1 only diff --git a/README.md b/README.md index 3394358..4f96404 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ go build -o jh . - `--limit ` - Maximum results to return (default: 10) - `--offset ` - Number of results to skip - `--json` - Print only `{"results": [...], "total": N}` on stdout (also available as `jh search packages`) -- `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 7e99315..ef5b898 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 { @@ -144,8 +163,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 @@ -240,9 +282,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 != "" { @@ -265,18 +316,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. @@ -677,6 +777,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)) } @@ -688,6 +798,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 ba9d8fa..1c27747 100644 --- a/packages_test.go +++ b/packages_test.go @@ -296,3 +296,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 8212eddf9ea0122c0ef0e9194343d30ec5061ce1 Mon Sep 17 00:00:00 2001 From: Panagiotis Georgakopoulos Date: Wed, 14 Oct 2026 19:59:49 +0300 Subject: [PATCH 2/3] 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; the OWNER separator and VERSION column used to be wider than their headers. 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: `deps` can hold a package as both direct and indirect, and `splitDependencies` keeps one entry per UUID (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`. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 + packages.go | 423 ++++++++++++++++++++++++++++------------------- packages_test.go | 39 +++++ style.go | 115 +++++++++++++ style_test.go | 51 ++++++ 5 files changed, 461 insertions(+), 169 deletions(-) create mode 100644 style.go create mode 100644 style_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 1e9dc34..12fbbe0 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) via the homepage layout API. The layout is a JSON widget array; the landing page is the `greeter` widget's `metadata.content` markdown. `update` replaces that content (adding a greeter as the first, full-width row and shifting the others down when none exists), `remove` drops the greeter widget; both POST the complete layout back, preserving every field the CLI does not model. There is no separate DELETE endpoint any more - **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` - **search.go**: `jh search code|symbols|docs` against the search service (`POST /search/v1/{code,sym,docs}`, legacy `POST /search/{code,sym,docs}` fallback), with request builders, response decoders, table renderers and the `--json` writer as pure functions; the cobra commands live in `main.go` - **git.go**: Git integration (clone, push, fetch, pull) with JuliaHub authentication - **julia.go**: Julia installation and management @@ -534,6 +535,7 @@ jh run setup - 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; `searchPackages` prints `warning: REST package search failed (...); falling back to GraphQL` to stderr when it falls back (`getPackageInfo` falls back silently) - `--registries` resolution for `jh package search` / `jh package info` / `jh search packages` goes through `resolveRegistries(server, csv)` in `registries.go` (empty = all registries; unknown name → `Registry not found: ''`) - `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 --json` gains 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 - `jh package search` / `jh search packages --json` sets `PackageSearchParams.JSON`; both the REST and GraphQL paths then call `writePackagesJSON`, which prints `{"results": [...], "total": N}` (snake_case keys from `packageInfo`'s json tags, `tags` always an array) 2-space indented with a trailing newline — nothing else on stdout - **JSON output convention** (`jh search *`): `--json` prints only the document on stdout, 2-space indented, trailing newline — `{"results": , "truncated": bool}` for code/symbols/docs; warnings, the human-mode `note: results truncated (search stopped early)` line, and errors always go to stderr; exit code is 0/1 only diff --git a/packages.go b/packages.go index ef5b898..71936af 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 @@ -224,94 +225,195 @@ func emitPackages(params PackageSearchParams, infos []packageInfo, total int) er if params.JSON { return writePackagesJSON(os.Stdout, infos, total) } - printPackages(infos, total, params.Verbose) + printPackages(params.Server, infos, total, params.Verbose) return nil } -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. @@ -794,45 +896,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) { @@ -966,72 +1035,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 @@ -1065,11 +1152,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)) } } @@ -1112,23 +1198,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 1c27747..886434d 100644 --- a/packages_test.go +++ b/packages_test.go @@ -345,3 +345,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 61ccbaee435e165522633526d23473d556e1e133 Mon Sep 17 00:00:00 2001 From: Panagiotis Georgakopoulos Date: Wed, 14 Oct 2026 20:14:17 +0300 Subject: [PATCH 3/3] 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 12fbbe0..890d669 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -535,7 +535,7 @@ jh run setup - 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; `searchPackages` prints `warning: REST package search failed (...); falling back to GraphQL` to stderr when it falls back (`getPackageInfo` falls back silently) - `--registries` resolution for `jh package search` / `jh package info` / `jh search packages` goes through `resolveRegistries(server, csv)` in `registries.go` (empty = all registries; unknown name → `Registry not found: ''`) - `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 --json` gains 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 - `jh package search` / `jh search packages --json` sets `PackageSearchParams.JSON`; both the REST and GraphQL paths then call `writePackagesJSON`, which prints `{"results": [...], "total": N}` (snake_case keys from `packageInfo`'s json tags, `tags` always an array) 2-space indented with a trailing newline — nothing else on stdout - **JSON output convention** (`jh search *`): `--json` prints only the document on stdout, 2-space indented, trailing newline — `{"results": , "truncated": bool}` for code/symbols/docs; warnings, the human-mode `note: results truncated (search stopped early)` line, and errors always go to stderr; exit code is 0/1 only diff --git a/README.md b/README.md index 4f96404..9abfda9 100644 --- a/README.md +++ b/README.md @@ -171,11 +171,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 54bc7a7..c1f4f1f 100644 --- a/main.go +++ b/main.go @@ -1039,7 +1039,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) @@ -1050,10 +1050,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) } @@ -1073,7 +1074,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) @@ -1084,10 +1085,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) } @@ -2657,10 +2659,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 71936af..ab7a9a7 100644 --- a/packages.go +++ b/packages.go @@ -1029,7 +1029,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 @@ -1045,13 +1045,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) @@ -1065,6 +1069,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)) @@ -1188,7 +1204,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 @@ -1198,13 +1214,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 886434d..199875c 100644 --- a/packages_test.go +++ b/packages_test.go @@ -384,3 +384,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) + } +}