From 560e425bb87b1fef8f9fa531a5c7abac2b0e6ce5 Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:12:14 +0100 Subject: [PATCH 1/4] Dataset search command --- cli/root.go | 2 + cmd/dataset/dataset.go | 13 ++++ cmd/dataset/search.go | 114 ++++++++++++++++++++++++++++++ cmd/dataset/search_test.go | 139 +++++++++++++++++++++++++++++++++++++ go.mod | 2 + go.sum | 2 - 6 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 cmd/dataset/dataset.go create mode 100644 cmd/dataset/search.go create mode 100644 cmd/dataset/search_test.go diff --git a/cli/root.go b/cli/root.go index e1ec2f0..9160ed7 100644 --- a/cli/root.go +++ b/cli/root.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/fang" "github.com/duneanalytics/cli/authconfig" "github.com/duneanalytics/cli/cmd/auth" + "github.com/duneanalytics/cli/cmd/dataset" "github.com/duneanalytics/cli/cmd/execution" "github.com/duneanalytics/cli/cmd/query" "github.com/duneanalytics/cli/cmdutil" @@ -63,6 +64,7 @@ var rootCmd = &cobra.Command{ func init() { rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "Dune API key (overrides DUNE_API_KEY env var)") rootCmd.AddCommand(auth.NewAuthCmd()) + rootCmd.AddCommand(dataset.NewDatasetCmd()) rootCmd.AddCommand(query.NewQueryCmd()) rootCmd.AddCommand(execution.NewExecutionCmd()) } diff --git a/cmd/dataset/dataset.go b/cmd/dataset/dataset.go new file mode 100644 index 0000000..157d0e6 --- /dev/null +++ b/cmd/dataset/dataset.go @@ -0,0 +1,13 @@ +package dataset + +import "github.com/spf13/cobra" + +// NewDatasetCmd returns the `dataset` parent command. +func NewDatasetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dataset", + Short: "Manage Dune datasets", + } + cmd.AddCommand(newSearchCmd()) + return cmd +} diff --git a/cmd/dataset/search.go b/cmd/dataset/search.go new file mode 100644 index 0000000..467e872 --- /dev/null +++ b/cmd/dataset/search.go @@ -0,0 +1,114 @@ +package dataset + +import ( + "fmt" + "strings" + + "github.com/duneanalytics/cli/cmdutil" + "github.com/duneanalytics/cli/output" + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/spf13/cobra" +) + +func newSearchCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "search", + Short: "Search for datasets across the Dune catalog", + RunE: runSearch, + } + + cmd.Flags().String("query", "", "search query text") + cmd.Flags().StringArray("categories", nil, "filter by category (canonical, decoded, spell, community)") + cmd.Flags().StringArray("blockchains", nil, "filter by blockchain") + cmd.Flags().StringArray("dataset-types", nil, "filter by dataset type") + cmd.Flags().StringArray("schemas", nil, "filter by schema") + cmd.Flags().String("owner-scope", "", "ownership filter (all, me, team)") + cmd.Flags().Bool("include-private", false, "include private datasets") + cmd.Flags().Bool("include-schema", false, "include column schema in results") + cmd.Flags().Bool("include-metadata", false, "include metadata in results") + cmd.Flags().Int32("limit", 20, "maximum number of results") + cmd.Flags().Int32("offset", 0, "pagination offset") + output.AddFormatFlag(cmd, "text") + + return cmd +} + +func runSearch(cmd *cobra.Command, _ []string) error { + client := cmdutil.ClientFromCmd(cmd) + + req := models.SearchDatasetsRequest{} + + if cmd.Flags().Changed("query") { + v, _ := cmd.Flags().GetString("query") + req.Query = &v + } + if cmd.Flags().Changed("categories") { + v, _ := cmd.Flags().GetStringArray("categories") + req.Categories = v + } + if cmd.Flags().Changed("blockchains") { + v, _ := cmd.Flags().GetStringArray("blockchains") + req.Blockchains = v + } + if cmd.Flags().Changed("dataset-types") { + v, _ := cmd.Flags().GetStringArray("dataset-types") + req.DatasetTypes = v + } + if cmd.Flags().Changed("schemas") { + v, _ := cmd.Flags().GetStringArray("schemas") + req.Schemas = v + } + if cmd.Flags().Changed("owner-scope") { + v, _ := cmd.Flags().GetString("owner-scope") + req.OwnerScope = &v + } + if cmd.Flags().Changed("include-private") { + v, _ := cmd.Flags().GetBool("include-private") + req.IncludePrivate = &v + } + if cmd.Flags().Changed("include-schema") { + v, _ := cmd.Flags().GetBool("include-schema") + req.IncludeSchema = &v + } + if cmd.Flags().Changed("include-metadata") { + v, _ := cmd.Flags().GetBool("include-metadata") + req.IncludeMetadata = &v + } + if cmd.Flags().Changed("limit") { + v, _ := cmd.Flags().GetInt32("limit") + req.Limit = &v + } + if cmd.Flags().Changed("offset") { + v, _ := cmd.Flags().GetInt32("offset") + req.Offset = &v + } + + resp, err := client.SearchDatasets(req) + if err != nil { + return err + } + + w := cmd.OutOrStdout() + switch output.FormatFromCmd(cmd) { + case output.FormatJSON: + return output.PrintJSON(w, resp) + default: + columns := []string{"FULL_NAME", "CATEGORY", "DATASET_TYPE", "BLOCKCHAINS"} + rows := make([][]string, len(resp.Results)) + for i, r := range resp.Results { + dt := "" + if r.DatasetType != nil { + dt = *r.DatasetType + } + rows[i] = []string{ + r.FullName, + r.Category, + dt, + strings.Join(r.Blockchains, ", "), + } + } + output.PrintTable(w, columns, rows) + fmt.Fprintf(w, "\n%d of %d results\n", len(resp.Results), resp.Total) + return nil + } +} diff --git a/cmd/dataset/search_test.go b/cmd/dataset/search_test.go new file mode 100644 index 0000000..727445b --- /dev/null +++ b/cmd/dataset/search_test.go @@ -0,0 +1,139 @@ +package dataset_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/duneanalytics/cli/cmd/dataset" + "github.com/duneanalytics/cli/cmdutil" + "github.com/duneanalytics/duneapi-client-go/dune" + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockClient struct { + dune.DuneClient + searchDatasetsFn func(models.SearchDatasetsRequest) (*models.SearchDatasetsResponse, error) +} + +func (m *mockClient) SearchDatasets(req models.SearchDatasetsRequest) (*models.SearchDatasetsResponse, error) { + return m.searchDatasetsFn(req) +} + +func newTestRoot(mock dune.DuneClient) (*cobra.Command, *bytes.Buffer) { + root := &cobra.Command{ + Use: "dune", + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + cmdutil.SetClient(cmd, mock) + }, + } + root.SetContext(context.Background()) + root.AddCommand(dataset.NewDatasetCmd()) + + var buf bytes.Buffer + root.SetOut(&buf) + + return root, &buf +} + +func TestSearchSuccess(t *testing.T) { + dt := "spell" + var gotReq models.SearchDatasetsRequest + + mock := &mockClient{ + searchDatasetsFn: func(req models.SearchDatasetsRequest) (*models.SearchDatasetsResponse, error) { + gotReq = req + return &models.SearchDatasetsResponse{ + Total: 1, + Results: []models.SearchDatasetResult{ + { + FullName: "dex.trades", + Category: "spell", + DatasetType: &dt, + Blockchains: []string{"ethereum", "arbitrum"}, + }, + }, + Pagination: models.SearchDatasetsPagination{ + Limit: 5, + Offset: 0, + HasMore: false, + }, + }, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{ + "dataset", "search", + "--query", "dex trades", + "--categories", "spell", + "--blockchains", "ethereum", + "--limit", "5", + }) + + require.NoError(t, root.Execute()) + + // Verify flags mapped to request + assert.Equal(t, "dex trades", *gotReq.Query) + assert.Equal(t, []string{"spell"}, gotReq.Categories) + assert.Equal(t, []string{"ethereum"}, gotReq.Blockchains) + assert.Equal(t, int32(5), *gotReq.Limit) + + // Verify table output + out := buf.String() + assert.Contains(t, out, "dex.trades") + assert.Contains(t, out, "spell") + assert.Contains(t, out, "ethereum, arbitrum") + assert.Contains(t, out, "1 of 1 results") +} + +func TestSearchJSON(t *testing.T) { + mock := &mockClient{ + searchDatasetsFn: func(req models.SearchDatasetsRequest) (*models.SearchDatasetsResponse, error) { + return &models.SearchDatasetsResponse{ + Total: 1, + Results: []models.SearchDatasetResult{ + { + FullName: "ethereum.transactions", + Category: "canonical", + }, + }, + Pagination: models.SearchDatasetsPagination{ + Limit: 20, + Offset: 0, + HasMore: false, + }, + }, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"dataset", "search", "--query", "ethereum", "-o", "json"}) + + require.NoError(t, root.Execute()) + + var resp models.SearchDatasetsResponse + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + assert.Equal(t, int32(1), resp.Total) + assert.Equal(t, "ethereum.transactions", resp.Results[0].FullName) +} + +func TestSearchError(t *testing.T) { + mock := &mockClient{ + searchDatasetsFn: func(req models.SearchDatasetsRequest) (*models.SearchDatasetsResponse, error) { + return nil, fmt.Errorf("API error: unauthorized") + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"dataset", "search", "--query", "test"}) + + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "API error: unauthorized") +} diff --git a/go.mod b/go.mod index fef9678..546c484 100644 --- a/go.mod +++ b/go.mod @@ -39,3 +39,5 @@ require ( golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.24.0 // indirect ) + +replace github.com/duneanalytics/duneapi-client-go => ../../duneapi-client-go diff --git a/go.sum b/go.sum index 95aa7b8..4f335a9 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,6 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/duneanalytics/duneapi-client-go v0.4.0 h1:uK3cCIjhuMl3mGEfNivzj7AoA2H4T6QSgSnIIO4IlWI= -github.com/duneanalytics/duneapi-client-go v0.4.0/go.mod h1:7pXXufWvR/Mh2KOehdyBaunJXmHI+pzjUmyQTQhJjdE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= From 569ef4ce3c324c969ab1aa5273333cbb2c057458 Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:17:32 +0100 Subject: [PATCH 2/4] use new sdk version --- go.mod | 4 +--- go.sum | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 546c484..dd86a8b 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.6 require ( github.com/charmbracelet/fang v0.4.4 - github.com/duneanalytics/duneapi-client-go v0.4.0 + github.com/duneanalytics/duneapi-client-go v0.4.1 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 @@ -39,5 +39,3 @@ require ( golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.24.0 // indirect ) - -replace github.com/duneanalytics/duneapi-client-go => ../../duneapi-client-go diff --git a/go.sum b/go.sum index 4f335a9..2010255 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/duneanalytics/duneapi-client-go v0.4.1 h1:lO7rAPaZrSuNTBOHNBoZDJ3oa7aT35yv7a/dreX8Nms= +github.com/duneanalytics/duneapi-client-go v0.4.1/go.mod h1:7pXXufWvR/Mh2KOehdyBaunJXmHI+pzjUmyQTQhJjdE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= From c7606b73ff20b47126534abb483f5047b38717d0 Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:58:31 +0100 Subject: [PATCH 3/4] Add usage command --- cli/root.go | 2 + cmd/usage/usage.go | 90 ++++++++++++++++++++++ cmd/usage/usage_test.go | 160 ++++++++++++++++++++++++++++++++++++++++ output/output.go | 22 ++++++ 4 files changed, 274 insertions(+) create mode 100644 cmd/usage/usage.go create mode 100644 cmd/usage/usage_test.go diff --git a/cli/root.go b/cli/root.go index e1ec2f0..f5f76dc 100644 --- a/cli/root.go +++ b/cli/root.go @@ -11,6 +11,7 @@ import ( "github.com/duneanalytics/cli/cmd/auth" "github.com/duneanalytics/cli/cmd/execution" "github.com/duneanalytics/cli/cmd/query" + "github.com/duneanalytics/cli/cmd/usage" "github.com/duneanalytics/cli/cmdutil" "github.com/duneanalytics/duneapi-client-go/config" "github.com/duneanalytics/duneapi-client-go/dune" @@ -65,6 +66,7 @@ func init() { rootCmd.AddCommand(auth.NewAuthCmd()) rootCmd.AddCommand(query.NewQueryCmd()) rootCmd.AddCommand(execution.NewExecutionCmd()) + rootCmd.AddCommand(usage.NewUsageCmd()) } // Execute runs the root command via Fang. diff --git a/cmd/usage/usage.go b/cmd/usage/usage.go new file mode 100644 index 0000000..c219196 --- /dev/null +++ b/cmd/usage/usage.go @@ -0,0 +1,90 @@ +package usage + +import ( + "fmt" + "time" + + "github.com/duneanalytics/cli/cmdutil" + "github.com/duneanalytics/cli/output" + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/spf13/cobra" +) + +// NewUsageCmd returns the top-level "usage" command. +func NewUsageCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "usage", + Short: "Show credit and resource usage for your Dune account", + RunE: runUsage, + } + + cmd.Flags().String("start-date", "", "filter start date (YYYY-MM-DD)") + cmd.Flags().String("end-date", "", "filter end date (YYYY-MM-DD)") + output.AddFormatFlag(cmd, "text") + + return cmd +} + +func runUsage(cmd *cobra.Command, _ []string) error { + client := cmdutil.ClientFromCmd(cmd) + + startDate, _ := cmd.Flags().GetString("start-date") + endDate, _ := cmd.Flags().GetString("end-date") + + if err := validateDateFlag(startDate, "start-date"); err != nil { + return err + } + if err := validateDateFlag(endDate, "end-date"); err != nil { + return err + } + + var ( + resp *models.UsageResponse + err error + ) + if startDate != "" || endDate != "" { + resp, err = client.GetUsageForDates(startDate, endDate) + } else { + resp, err = client.GetUsage() + } + if err != nil { + return err + } + + w := cmd.OutOrStdout() + switch output.FormatFromCmd(cmd) { + case output.FormatJSON: + return output.PrintJSON(w, resp) + default: + fmt.Fprintf(w, "Private Queries: %d\n", resp.PrivateQueries) + fmt.Fprintf(w, "Private Dashboards: %d\n", resp.PrivateDashboards) + fmt.Fprintf(w, "Storage Used: %s / %s\n", + output.FormatBytes(resp.BytesUsed), output.FormatBytes(resp.BytesAllowed)) + + if len(resp.BillingPeriods) > 0 { + fmt.Fprintln(w) + columns := []string{"START DATE", "END DATE", "CREDITS USED", "CREDITS INCLUDED"} + rows := make([][]string, len(resp.BillingPeriods)) + for i, bp := range resp.BillingPeriods { + rows[i] = []string{ + bp.StartDate, + bp.EndDate, + fmt.Sprintf("%.2f", bp.CreditsUsed), + fmt.Sprintf("%d", bp.CreditsIncluded), + } + } + output.PrintTable(w, columns, rows) + } + return nil + } +} + +func validateDateFlag(value, name string) error { + if value == "" { + return nil + } + if _, err := time.Parse("2006-01-02", value); err != nil { + return fmt.Errorf("invalid --%s: expected YYYY-MM-DD format", name) + } + return nil +} diff --git a/cmd/usage/usage_test.go b/cmd/usage/usage_test.go new file mode 100644 index 0000000..41eacdb --- /dev/null +++ b/cmd/usage/usage_test.go @@ -0,0 +1,160 @@ +package usage_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/duneanalytics/cli/cmd/usage" + "github.com/duneanalytics/cli/cmdutil" + "github.com/duneanalytics/duneapi-client-go/dune" + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockClient struct { + dune.DuneClient + getUsageFn func() (*models.UsageResponse, error) + getUsageForDatesFn func(string, string) (*models.UsageResponse, error) +} + +func (m *mockClient) GetUsage() (*models.UsageResponse, error) { + return m.getUsageFn() +} + +func (m *mockClient) GetUsageForDates(start, end string) (*models.UsageResponse, error) { + return m.getUsageForDatesFn(start, end) +} + +func newTestRoot(mock dune.DuneClient) (*cobra.Command, *bytes.Buffer) { + root := &cobra.Command{ + Use: "dune", + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + cmdutil.SetClient(cmd, mock) + }, + } + root.SetContext(context.Background()) + root.AddCommand(usage.NewUsageCmd()) + + var buf bytes.Buffer + root.SetOut(&buf) + + return root, &buf +} + +func sampleUsageResponse() *models.UsageResponse { + return &models.UsageResponse{ + PrivateQueries: 5, + PrivateDashboards: 2, + BytesUsed: 1288490188, // ~1.2 GB + BytesAllowed: 10737418240, + BillingPeriods: []models.BillingPeriod{ + { + StartDate: "2025-03-01", + EndDate: "2025-04-01", + CreditsUsed: 450, + CreditsIncluded: 1000, + }, + }, + } +} + +func TestUsageTextOutput(t *testing.T) { + mock := &mockClient{ + getUsageFn: func() (*models.UsageResponse, error) { + return sampleUsageResponse(), nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"usage"}) + require.NoError(t, root.Execute()) + + out := buf.String() + assert.Contains(t, out, "Private Queries: 5") + assert.Contains(t, out, "Private Dashboards: 2") + assert.Contains(t, out, "1.2 GB") + assert.Contains(t, out, "10.0 GB") + assert.Contains(t, out, "2025-03-01") + assert.Contains(t, out, "450.00") + assert.Contains(t, out, "1000") +} + +func TestUsageJSONOutput(t *testing.T) { + mock := &mockClient{ + getUsageFn: func() (*models.UsageResponse, error) { + return sampleUsageResponse(), nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"usage", "-o", "json"}) + require.NoError(t, root.Execute()) + + var got models.UsageResponse + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, 5, got.PrivateQueries) + assert.Equal(t, 2, got.PrivateDashboards) + assert.Len(t, got.BillingPeriods, 1) + assert.Equal(t, float64(450), got.BillingPeriods[0].CreditsUsed) +} + +func TestUsageAPIError(t *testing.T) { + mock := &mockClient{ + getUsageFn: func() (*models.UsageResponse, error) { + return nil, errors.New("api: unauthorized") + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"usage"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "api: unauthorized") +} + +func TestUsageWithDateFlags(t *testing.T) { + var gotStart, gotEnd string + mock := &mockClient{ + getUsageForDatesFn: func(start, end string) (*models.UsageResponse, error) { + gotStart = start + gotEnd = end + return sampleUsageResponse(), nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"usage", "--start-date", "2025-01-01", "--end-date", "2025-02-01"}) + require.NoError(t, root.Execute()) + assert.Equal(t, "2025-01-01", gotStart) + assert.Equal(t, "2025-02-01", gotEnd) +} + +func TestUsageWithOnlyStartDate(t *testing.T) { + var called bool + mock := &mockClient{ + getUsageForDatesFn: func(start, end string) (*models.UsageResponse, error) { + called = true + assert.Equal(t, "2025-01-01", start) + assert.Equal(t, "", end) + return sampleUsageResponse(), nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"usage", "--start-date", "2025-01-01"}) + require.NoError(t, root.Execute()) + assert.True(t, called) +} + +func TestUsageInvalidDateFormat(t *testing.T) { + root, _ := newTestRoot(&mockClient{}) + root.SetArgs([]string{"usage", "--start-date", "not-a-date"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --start-date") +} diff --git a/output/output.go b/output/output.go index e6d14d4..f41fb6e 100644 --- a/output/output.go +++ b/output/output.go @@ -55,3 +55,25 @@ func PrintTable(w io.Writer, columns []string, rows [][]string) { tw.Flush() } +// FormatBytes returns a human-readable byte size string (e.g. "1.2 GB"). +func FormatBytes(b int64) string { + const ( + kb = 1024 + mb = kb * 1024 + gb = mb * 1024 + tb = gb * 1024 + ) + switch { + case b >= tb: + return fmt.Sprintf("%.1f TB", float64(b)/float64(tb)) + case b >= gb: + return fmt.Sprintf("%.1f GB", float64(b)/float64(gb)) + case b >= mb: + return fmt.Sprintf("%.1f MB", float64(b)/float64(mb)) + case b >= kb: + return fmt.Sprintf("%.1f KB", float64(b)/float64(kb)) + default: + return fmt.Sprintf("%d B", b) + } +} + From 3cdc4ba6256f89b92faa54c0ae346527096043e1 Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:03:01 +0100 Subject: [PATCH 4/4] Add readme file --- README.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/README.md b/README.md index a6f372a..85f09de 100644 --- a/README.md +++ b/README.md @@ -1 +1,60 @@ # Dune CLI + +A command-line interface for interacting with the Dune Analytics API. + +## Authentication + +```bash +# Save your API key to ~/.config/dune/config.yaml +dune auth --api-key + +# Or set via environment variable +export DUNE_API_KEY= +``` + +The `--api-key` flag is available on all commands to override the stored key. + +## Commands + +### `dune query` + +Manage and execute Dune queries. + +| Command | Description | +|---------|-------------| +| `query create --name --sql [--description] [--private]` | Create a new saved query | +| `query get ` | Get a saved query's details and SQL | +| `query update [--name] [--sql] [--description] [--private] [--tags]` | Update an existing query | +| `query archive ` | Archive a saved query | +| `query run [--param key=value] [--performance medium\|large] [--limit] [--no-wait]` | Execute a saved query and display results | +| `query run-sql --sql [--param key=value] [--performance medium\|large] [--limit] [--no-wait]` | Execute raw SQL directly | + +### `dune execution` + +Manage query executions. + +| Command | Description | +|---------|-------------| +| `execution results [--limit] [--offset]` | Fetch results of a query execution | + +### `dune dataset` + +Search the Dune dataset catalog. + +| Command | Description | +|---------|-------------| +| `dataset search [--query] [--categories] [--blockchains] [--schemas] [--dataset-types] [--owner-scope] [--include-private] [--include-schema] [--include-metadata] [--limit] [--offset]` | Search for datasets | + +Categories: `canonical`, `decoded`, `spell`, `community` + +### `dune usage` + +Show credit and resource usage for your account. + +```bash +dune usage [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] +``` + +## Output Format + +Most commands support `-o, --output ` with `text` (default) or `json`.