Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <key>

# Or set via environment variable
export DUNE_API_KEY=<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 <name> --sql <sql> [--description] [--private]` | Create a new saved query |
| `query get <query-id>` | Get a saved query's details and SQL |
| `query update <query-id> [--name] [--sql] [--description] [--private] [--tags]` | Update an existing query |
| `query archive <query-id>` | Archive a saved query |
| `query run <query-id> [--param key=value] [--performance medium\|large] [--limit] [--no-wait]` | Execute a saved query and display results |
| `query run-sql --sql <sql> [--param key=value] [--performance medium\|large] [--limit] [--no-wait]` | Execute raw SQL directly |

### `dune execution`

Manage query executions.

| Command | Description |
|---------|-------------|
| `execution results <execution-id> [--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 <format>` with `text` (default) or `json`.
4 changes: 4 additions & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ 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/cmd/usage"
"github.com/duneanalytics/cli/cmdutil"
"github.com/duneanalytics/duneapi-client-go/config"
"github.com/duneanalytics/duneapi-client-go/dune"
Expand Down Expand Up @@ -63,8 +65,10 @@ 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())
rootCmd.AddCommand(usage.NewUsageCmd())
}

// Execute runs the root command via Fang.
Expand Down
13 changes: 13 additions & 0 deletions cmd/dataset/dataset.go
Original file line number Diff line number Diff line change
@@ -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
}
114 changes: 114 additions & 0 deletions cmd/dataset/search.go
Original file line number Diff line number Diff line change
@@ -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
}
}
139 changes: 139 additions & 0 deletions cmd/dataset/search_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading