Something went wrong. Try again.
A simple CLI utility for search nixpkgs
Something went wrong. Try again.
nixpkgs-search main.go
15 kB · 576 lines
Go
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577// nixpkgs-search: search nixpkgs like search.nixos.org, from the terminal.//// Hits the same Elasticsearch backend the web UI uses, then (optionally)// pipes results through fzf for fuzzy selection and prints full metadata// for the chosen package.package main
import ( "bufio" "bytes" "encoding/json" "errors" "flag" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "time")
// Public credentials used by search.nixos.org's frontend JS.const ( esHost = "https://search.nixos.org/backend" esUser = "aWVSALXpZv" esPass = "X8gPHnzL52wFEekuxsfQ9cSh")
type license struct { FullName string `json:"fullName"` ShortName string `json:"shortName"` SpdxID string `json:"spdxId"` URL string `json:"url"`}
type maintainer struct { Name string `json:"name"` Email string `json:"email"` GitHub string `json:"github"`}
type pkg struct { AttrName string `json:"package_attr_name"` Pname string `json:"package_pname"` Version string `json:"package_pversion"` Description string `json:"package_description"` LongDescription string `json:"package_longDescription"` Homepage any `json:"package_homepage"` // string or []string Licenses []license `json:"package_license"` Platforms []string `json:"package_platforms"` Maintainers []maintainer `json:"package_maintainers"` Programs []string `json:"package_programs"` Position string `json:"package_position"`}
type esResp struct { Hits struct { Hits []struct { Source pkg `json:"_source"` } `json:"hits"` } `json:"hits"`}
func buildQuery(term string, size int) map[string]any { // Mirrors the query shape search.nixos.org uses. return map[string]any{ "size": size, "from": 0, "query": map[string]any{ "bool": map[string]any{ "filter": []any{ map[string]any{"term": map[string]any{"type": "package"}}, }, "must": []any{ map[string]any{ "dis_max": map[string]any{ "tie_breaker": 0.7, "queries": []any{ map[string]any{ "multi_match": map[string]any{ "query": term, "type": "cross_fields", "analyzer": "whitespace", "operator": "and", "fields": []string{ "package_attr_name^9", "package_attr_name.*^5.4", "package_programs^9", "package_programs.*^5.4", "package_pname^6", "package_pname.*^3.6", "package_description^1.3", "package_description.*^0.78", "package_longDescription^1", "package_longDescription.*^0.6", }, }, }, map[string]any{ "wildcard": map[string]any{ "package_attr_name": map[string]any{ "value": "*" + term + "*", "case_insensitive": true, }, }, }, }, }, }, }, }, }, }}
// resolveIndex discovers the current `latest-<N>-nixos-<channel>` alias// by asking ES for all aliases and picking the highest N. The version// prefix bumps whenever the NixOS search infra reindexes, so hardcoding// it means the CLI rots.func resolveIndex(channel string) (string, error) { req, err := http.NewRequest("GET", esHost+"/_aliases", nil) if err != nil { return "", err } req.SetBasicAuth(esUser, esPass) client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } re := regexp.MustCompile(`latest-(\d+)-nixos-` + regexp.QuoteMeta(channel)) matches := re.FindAllStringSubmatch(string(body), -1) best := -1 for _, m := range matches { n, _ := strconv.Atoi(m[1]) if n > best { best = n } } if best < 0 { return "", fmt.Errorf("no latest-*-nixos-%s alias found", channel) } return fmt.Sprintf("latest-%d-nixos-%s", best, channel), nil}
func search(term, channel string, size int) ([]pkg, error) { index, err := resolveIndex(channel) if err != nil { return nil, err } url := esHost + "/" + index + "/_search" body, err := json.Marshal(buildQuery(term, size)) if err != nil { return nil, err } req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.SetBasicAuth(esUser, esPass)
client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { b, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("backend returned %d: %s", resp.StatusCode, string(b)) } var parsed esResp if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { return nil, err } out := make([]pkg, 0, len(parsed.Hits.Hits)) for _, h := range parsed.Hits.Hits { out = append(out, h.Source) } return out, nil}
func searchMany(terms []string, channel string, size int) ([]pkg, error) { seen := map[string]bool{} out := []pkg{} for _, term := range terms { pkgs, err := search(term, channel, size) if err != nil { return nil, fmt.Errorf("%s: %w", term, err) } for _, p := range pkgs { if seen[p.AttrName] { continue } seen[p.AttrName] = true out = append(out, p) } } return out, nil}
func truncate(s string, n int) string { s = strings.ReplaceAll(s, "\n", " ") if len(s) > n { return s[:n-3] + "..." } return s}
func shortLine(p pkg) string { return fmt.Sprintf("%s\t%s\t%s", p.AttrName, p.Version, truncate(p.Description, 100))}
func homepageString(h any) string { switch v := h.(type) { case string: return v case []any: parts := make([]string, 0, len(v)) for _, x := range v { if s, ok := x.(string); ok { parts = append(parts, s) } } return strings.Join(parts, ", ") } return ""}
func formatDetails(p pkg) string { var b strings.Builder fmt.Fprintf(&b, "\033[1;36m%s\033[0m \033[2m%s\033[0m\n", p.AttrName, p.Version) if p.Pname != "" && p.Pname != p.AttrName { fmt.Fprintf(&b, " pname: %s\n", p.Pname) } if p.Description != "" { fmt.Fprintf(&b, " description: %s\n", p.Description) } if p.LongDescription != "" { b.WriteString(" long description:\n") for _, l := range strings.Split(strings.TrimSpace(p.LongDescription), "\n") { fmt.Fprintf(&b, " %s\n", l) } } if hp := homepageString(p.Homepage); hp != "" { fmt.Fprintf(&b, " homepage: %s\n", hp) } if len(p.Licenses) > 0 { names := make([]string, 0, len(p.Licenses)) for _, l := range p.Licenses { switch { case l.FullName != "": names = append(names, l.FullName) case l.ShortName != "": names = append(names, l.ShortName) case l.SpdxID != "": names = append(names, l.SpdxID) } } if len(names) > 0 { fmt.Fprintf(&b, " license: %s\n", strings.Join(names, ", ")) } } if len(p.Platforms) > 0 { fmt.Fprintf(&b, " platforms: %s\n", strings.Join(p.Platforms, ", ")) } if len(p.Maintainers) > 0 { b.WriteString(" maintainers:\n") for _, m := range p.Maintainers { bits := []string{} if m.Name != "" { bits = append(bits, m.Name) } if m.GitHub != "" { bits = append(bits, "@"+m.GitHub) } if m.Email != "" { bits = append(bits, "<"+m.Email+">") } fmt.Fprintf(&b, " - %s\n", strings.Join(bits, " ")) } } if len(p.Programs) > 0 { fmt.Fprintf(&b, " programs: %s\n", strings.Join(p.Programs, ", ")) } if p.Position != "" { path := strings.SplitN(p.Position, ":", 2)[0] fmt.Fprintf(&b, " defined at: %s\n", p.Position) fmt.Fprintf(&b, " source: https://github.com/NixOS/nixpkgs/blob/nixos-unstable/%s\n", path) } fmt.Fprintf(&b, "\n install: nix-shell -p %s\n", p.AttrName) fmt.Fprintf(&b, " nix profile install nixpkgs#%s\n", p.AttrName) return b.String()}
// sanitizeAttr keeps filenames safe when we stash per-package previews// on disk. attr names can contain `.` and other shell-meaningful bits,// but nothing that would escape a directory — still, be defensive.func sanitizeAttr(s string) string { return strings.Map(func(r rune) rune { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-', r == '_', r == '+': return r } return '_' }, s)}
func pickWithFzf(pkgs []pkg, multi bool) ([]*pkg, error) { // Stash pre-rendered details for each package in a temp dir so fzf's // --preview can just `cat` them. Cheaper and simpler than having fzf // shell back into this binary per keystroke. dir, err := os.MkdirTemp("", "nixpkgs-search-*") if err != nil { return nil, err } defer os.RemoveAll(dir)
lines := make([]string, len(pkgs)) for i, p := range pkgs { lines[i] = shortLine(p) fname := filepath.Join(dir, sanitizeAttr(p.AttrName)) if err := os.WriteFile(fname, []byte(formatDetails(p)), 0o600); err != nil { return nil, err } }
previewCmd := fmt.Sprintf("cat %s/{1} 2>/dev/null", shellQuote(dir)) args := []string{ "--delimiter=\t", "--with-nth=1,2,3", "--height=80%", "--reverse", "--prompt=nixpkgs> ", "--header=attr\tversion\tdescription", "--preview", previewCmd, "--preview-window=right,60%,wrap", } if multi { args = append(args, "--multi") } cmd := exec.Command("fzf", args...) cmd.Stdin = strings.NewReader(strings.Join(lines, "\n")) cmd.Stderr = os.Stderr out, err := cmd.Output() if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { return nil, nil // user cancelled } return nil, err } selected := []*pkg{} for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { chosen := strings.SplitN(line, "\t", 2)[0] for i := range pkgs { if pkgs[i].AttrName == chosen { selected = append(selected, &pkgs[i]) break } } } return selected, nil}
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"}
func pickNumeric(r *bufio.Reader, pkgs []pkg, multi bool) []*pkg { for i, p := range pkgs { fmt.Fprintf(os.Stderr, "%3d. %s\n", i+1, shortLine(p)) } if multi { fmt.Fprint(os.Stderr, "\nselect #s (comma or space separated): ") } else { fmt.Fprint(os.Stderr, "\nselect #: ") } raw, err := r.ReadString('\n') if err != nil && raw == "" { return nil } raw = strings.TrimSpace(raw) fields := []string{raw} if multi { fields = strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) } selected := []*pkg{} seen := map[int]bool{} for _, field := range fields { var idx int if _, err := fmt.Sscanf(field, "%d", &idx); err != nil { return nil } idx-- if idx < 0 || idx >= len(pkgs) || seen[idx] { continue } seen[idx] = true selected = append(selected, &pkgs[idx]) } return selected}
func attrNames(pkgs []*pkg) []string { names := make([]string, 0, len(pkgs)) for _, p := range pkgs { names = append(names, p.AttrName) } return names}
func printAttrNames(pkgs []pkg) { for _, p := range pkgs { fmt.Println(p.AttrName) }}
func appendSelected(dst []*pkg, src []*pkg) []*pkg { seen := map[string]bool{} for _, p := range dst { seen[p.AttrName] = true } for _, p := range src { if seen[p.AttrName] { continue } seen[p.AttrName] = true dst = append(dst, p) } return dst}
func fzfAvailable() bool { _, err := exec.LookPath("fzf") return err == nil}
func isTTY(f *os.File) bool { fi, err := f.Stat() if err != nil { return false } return fi.Mode()&os.ModeCharDevice != 0}
func main() { channel := flag.String("channel", "unstable", "nix channel (e.g. unstable, 24.11)") size := flag.Int("n", 50, "max results") asJSON := flag.Bool("json", false, "dump raw JSON hits and exit") noFzf := flag.Bool("no-fzf", false, "don't use fzf even if available") nixery := flag.Bool("nixery", false, "select multiple packages and print a Nixery /-delimited package path") loop := flag.Bool("loop", false, "with --nixery, run one picker per query and join the selections") flag.BoolVar(loop, "l", false, "alias for --loop") flag.Usage = func() { fmt.Fprintf(os.Stderr, "usage: %s [flags] <query...>\n\n", os.Args[0]) flag.PrintDefaults() } flag.Parse() if flag.NArg() == 0 { flag.Usage() os.Exit(2) } args := flag.Args() term := strings.Join(args, " ") if *loop && !*nixery { fmt.Fprintln(os.Stderr, "error: --loop requires --nixery") os.Exit(2) }
var pkgs []pkg var err error if *nixery && *loop { pkgs = nil } else if *nixery { pkgs, err = searchMany(args, *channel, *size) } else { pkgs, err = search(term, *channel, *size) } if err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } if !(*nixery && *loop) && len(pkgs) == 0 { fmt.Fprintf(os.Stderr, "no results for %q on channel %s\n", term, *channel) os.Exit(1) }
if *asJSON { if *nixery && *loop { pkgs, err = searchMany(args, *channel, *size) if err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") _ = enc.Encode(pkgs) return }
interactive := isTTY(os.Stdin) && isTTY(os.Stdout) && isTTY(os.Stderr) if !interactive { if *nixery && *loop { pkgs, err = searchMany(args, *channel, *size) if err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } printAttrNames(pkgs) return }
useFzf := !*noFzf && fzfAvailable() reader := bufio.NewReader(os.Stdin) var chosen []*pkg if *nixery && *loop { for _, query := range args { queryPkgs, err := search(query, *channel, *size) if err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } if len(queryPkgs) == 0 { fmt.Fprintf(os.Stderr, "no results for %q on channel %s\n", query, *channel) continue } if useFzf { fmt.Fprintf(os.Stderr, "\nquery: %s\n", query) selected, err := pickWithFzf(queryPkgs, true) if err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } chosen = appendSelected(chosen, selected) } else { fmt.Fprintf(os.Stderr, "\nquery: %s\n", query) chosen = appendSelected(chosen, pickNumeric(reader, queryPkgs, true)) } } } else { if useFzf { chosen, err = pickWithFzf(pkgs, *nixery) if err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } else { chosen = pickNumeric(reader, pkgs, *nixery) } } if len(chosen) == 0 { return } if *nixery { fmt.Println(strings.Join(attrNames(chosen), "/")) } else { fmt.Println(chosen[0].AttrName) }}