From 10de071704696363210955cfcc62de34fc00da61 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 19:59:57 +0000 Subject: [PATCH] feat(pkg): add JSON output for package search Co-Authored-By: Virgil --- cmd/core/pkgcmd/cmd_manage.go | 6 ++ cmd/core/pkgcmd/cmd_search.go | 99 +++++++++++++++++++++++++++--- cmd/core/pkgcmd/cmd_search_test.go | 42 +++++++++++++ docs/cmd/pkg/search/index.md | 4 ++ go.mod | 2 +- go.sum | 4 +- 6 files changed, 144 insertions(+), 13 deletions(-) diff --git a/cmd/core/pkgcmd/cmd_manage.go b/cmd/core/pkgcmd/cmd_manage.go index 7df7f95..341edde 100644 --- a/cmd/core/pkgcmd/cmd_manage.go +++ b/cmd/core/pkgcmd/cmd_manage.go @@ -1,11 +1,13 @@ package pkgcmd import ( + "cmp" "encoding/json" "errors" "fmt" "os/exec" "path/filepath" + "slices" "strings" "forge.lthn.ai/core/go-i18n" @@ -74,6 +76,10 @@ func runPkgList(format string) error { return nil } + slices.SortFunc(allRepos, func(a, b *repos.Repo) int { + return cmp.Compare(a.Name, b.Name) + }) + var entries []pkgListEntry var installed, missing int for _, r := range allRepos { diff --git a/cmd/core/pkgcmd/cmd_search.go b/cmd/core/pkgcmd/cmd_search.go index 888f3e6..bc7c85d 100644 --- a/cmd/core/pkgcmd/cmd_search.go +++ b/cmd/core/pkgcmd/cmd_search.go @@ -26,6 +26,7 @@ var ( searchType string searchLimit int searchRefresh bool + searchFormat string ) // addPkgSearchCommand adds the 'pkg search' command. @@ -45,7 +46,7 @@ func addPkgSearchCommand(parent *cobra.Command) { if limit == 0 { limit = 50 } - return runPkgSearch(org, pattern, searchType, limit, searchRefresh) + return runPkgSearch(org, pattern, searchType, limit, searchRefresh, searchFormat) }, } @@ -54,13 +55,14 @@ func addPkgSearchCommand(parent *cobra.Command) { searchCmd.Flags().StringVar(&searchType, "type", "", i18n.T("cmd.pkg.search.flag.type")) searchCmd.Flags().IntVar(&searchLimit, "limit", 0, i18n.T("cmd.pkg.search.flag.limit")) searchCmd.Flags().BoolVar(&searchRefresh, "refresh", false, i18n.T("cmd.pkg.search.flag.refresh")) + searchCmd.Flags().StringVar(&searchFormat, "format", "table", "Output format: table or json") parent.AddCommand(searchCmd) } type ghRepo struct { - Name string `json:"name"` FullName string `json:"fullName"` + Name string `json:"name"` Description string `json:"description"` Visibility string `json:"visibility"` UpdatedAt string `json:"updatedAt"` @@ -72,7 +74,29 @@ type ghLanguage struct { Name string `json:"name"` } -func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error { +type pkgSearchEntry struct { + Name string `json:"name"` + FullName string `json:"fullName,omitempty"` + Description string `json:"description,omitempty"` + Visibility string `json:"visibility,omitempty"` + StargazerCount int `json:"stargazerCount,omitempty"` + PrimaryLanguage string `json:"primaryLanguage,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + Updated string `json:"updated,omitempty"` +} + +type pkgSearchReport struct { + Format string `json:"format"` + Org string `json:"org"` + Pattern string `json:"pattern"` + Type string `json:"type,omitempty"` + Limit int `json:"limit"` + Cached bool `json:"cached"` + Count int `json:"count"` + Repos []pkgSearchEntry `json:"repos"` +} + +func runPkgSearch(org, pattern, repoType string, limit int, refresh bool, format string) error { // Initialize cache in workspace .core/ directory var cacheDir string if regPath, err := repos.FindRegistry(coreio.Local); err == nil { @@ -92,8 +116,6 @@ func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error if c != nil && !refresh { if found, err := c.Get(cacheKey, &ghRepos); found && err == nil { fromCache = true - age := c.Age(cacheKey) - fmt.Printf("%s %s %s\n", dimStyle.Render(i18n.T("cmd.pkg.search.cache_label")), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second)))) } } @@ -103,20 +125,24 @@ func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error return errors.New(i18n.T("cmd.pkg.error.gh_not_authenticated")) } - if os.Getenv("GH_TOKEN") != "" { + if os.Getenv("GH_TOKEN") != "" && !strings.EqualFold(format, "json") { fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("note")), i18n.T("cmd.pkg.search.gh_token_warning")) fmt.Printf("%s %s\n\n", dimStyle.Render(""), i18n.T("cmd.pkg.search.gh_token_unset")) } - fmt.Printf("%s %s... ", dimStyle.Render(i18n.T("cmd.pkg.search.fetching_label")), org) + if !strings.EqualFold(format, "json") { + fmt.Printf("%s %s... ", dimStyle.Render(i18n.T("cmd.pkg.search.fetching_label")), org) + } cmd := exec.Command("gh", "repo", "list", org, - "--json", "name,description,visibility,updatedAt,stargazerCount,primaryLanguage", + "--json", "fullName,name,description,visibility,updatedAt,stargazerCount,primaryLanguage", "--limit", fmt.Sprintf("%d", limit)) output, err := cmd.CombinedOutput() if err != nil { - fmt.Println() + if !strings.EqualFold(format, "json") { + fmt.Println() + } errStr := strings.TrimSpace(string(output)) if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") { return errors.New(i18n.T("cmd.pkg.error.auth_failed")) @@ -132,7 +158,9 @@ func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error _ = c.Set(cacheKey, ghRepos) } - fmt.Printf("%s\n", successStyle.Render("✓")) + if !strings.EqualFold(format, "json") { + fmt.Printf("%s\n", successStyle.Render("✓")) + } } // Filter by glob pattern and type @@ -148,6 +176,10 @@ func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error } if len(filtered) == 0 { + if strings.EqualFold(format, "json") { + report := buildPkgSearchReport(org, pattern, repoType, limit, fromCache, filtered) + return printPkgSearchJSON(report) + } fmt.Println(i18n.T("cmd.pkg.search.no_repos_found")) return nil } @@ -156,6 +188,15 @@ func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error return cmp.Compare(a.Name, b.Name) }) + if strings.EqualFold(format, "json") { + report := buildPkgSearchReport(org, pattern, repoType, limit, fromCache, filtered) + return printPkgSearchJSON(report) + } + + if fromCache && !strings.EqualFold(format, "json") { + age := c.Age(cacheKey) + fmt.Printf("%s %s %s\n", dimStyle.Render(i18n.T("cmd.pkg.search.cache_label")), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second)))) + } renderPkgSearchResults(filtered) fmt.Println() @@ -190,6 +231,44 @@ func renderPkgSearchResults(repos []ghRepo) { } } +func buildPkgSearchReport(org, pattern, repoType string, limit int, cached bool, repos []ghRepo) pkgSearchReport { + report := pkgSearchReport{ + Format: "json", + Org: org, + Pattern: pattern, + Type: repoType, + Limit: limit, + Cached: cached, + Count: len(repos), + Repos: make([]pkgSearchEntry, 0, len(repos)), + } + + for _, r := range repos { + report.Repos = append(report.Repos, pkgSearchEntry{ + Name: r.Name, + FullName: r.FullName, + Description: r.Description, + Visibility: r.Visibility, + StargazerCount: r.StargazerCount, + PrimaryLanguage: strings.TrimSpace(r.PrimaryLanguage.Name), + UpdatedAt: r.UpdatedAt, + Updated: formatPkgSearchUpdatedAt(r.UpdatedAt), + }) + } + + return report +} + +func printPkgSearchJSON(report pkgSearchReport) error { + out, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("%s: %w", i18n.T("i18n.fail.format", "search results"), err) + } + + fmt.Println(string(out)) + return nil +} + func formatPkgSearchMetadata(r ghRepo) string { var parts []string diff --git a/cmd/core/pkgcmd/cmd_search_test.go b/cmd/core/pkgcmd/cmd_search_test.go index f0477fc..891a7e1 100644 --- a/cmd/core/pkgcmd/cmd_search_test.go +++ b/cmd/core/pkgcmd/cmd_search_test.go @@ -1,6 +1,7 @@ package pkgcmd import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -22,3 +23,44 @@ func TestResolvePkgSearchPattern_Good(t *testing.T) { assert.Equal(t, "*", got) }) } + +func TestBuildPkgSearchReport_Good(t *testing.T) { + repos := []ghRepo{ + { + FullName: "host-uk/core-api", + Name: "core-api", + Description: "REST API framework", + Visibility: "public", + UpdatedAt: "2026-03-30T12:00:00Z", + StargazerCount: 42, + PrimaryLanguage: ghLanguage{ + Name: "Go", + }, + }, + } + + report := buildPkgSearchReport("host-uk", "core-*", "api", 50, true, repos) + + assert.Equal(t, "json", report.Format) + assert.Equal(t, "host-uk", report.Org) + assert.Equal(t, "core-*", report.Pattern) + assert.Equal(t, "api", report.Type) + assert.Equal(t, 50, report.Limit) + assert.True(t, report.Cached) + assert.Equal(t, 1, report.Count) + requireRepo := report.Repos + if assert.Len(t, requireRepo, 1) { + assert.Equal(t, "core-api", requireRepo[0].Name) + assert.Equal(t, "host-uk/core-api", requireRepo[0].FullName) + assert.Equal(t, "REST API framework", requireRepo[0].Description) + assert.Equal(t, "public", requireRepo[0].Visibility) + assert.Equal(t, 42, requireRepo[0].StargazerCount) + assert.Equal(t, "Go", requireRepo[0].PrimaryLanguage) + assert.Equal(t, "2026-03-30T12:00:00Z", requireRepo[0].UpdatedAt) + assert.NotEmpty(t, requireRepo[0].Updated) + } + + out, err := json.Marshal(report) + assert.NoError(t, err) + assert.Contains(t, string(out), `"format":"json"`) +} diff --git a/docs/cmd/pkg/search/index.md b/docs/cmd/pkg/search/index.md index 57fea91..40345e3 100644 --- a/docs/cmd/pkg/search/index.md +++ b/docs/cmd/pkg/search/index.md @@ -19,6 +19,7 @@ core pkg search [flags] | `--type` | Filter by type in name (mod, services, plug, website) | | `--limit` | Max results (default: 50) | | `--refresh` | Bypass cache and fetch fresh data | +| `--format` | Output format (`table` or `json`) | ## Examples @@ -40,6 +41,9 @@ core pkg search --refresh # Combine filters core pkg search --pattern "core-*" --type mod --limit 20 + +# JSON output for automation +core pkg search --format json ``` ## Output diff --git a/go.mod b/go.mod index 9ab2521..453b1a1 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( ) require ( - forge.lthn.ai/core/go v0.3.2 // indirect + forge.lthn.ai/core/go v0.3.3 // indirect forge.lthn.ai/core/go-inference v0.1.7 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect diff --git a/go.sum b/go.sum index b3913d6..7085683 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA= dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= -forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ= -forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo= +forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= +forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ= forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA= forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8= forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=