diff --git a/.golangci.yml b/.golangci.yml index 76b485d..3fe4f56 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -8,7 +8,7 @@ linters: - errcheck run: - go: '1.22' + go: '1.25' issues: exclude-rules: diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7927fe4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dune Analytics + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 218228d..59d4ee4 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ A command-line interface for interacting with the Dune Analytics API. # Save your API key to ~/.config/dune/config.yaml dune auth --api-key +# Or run interactively (prompts for key) +dune auth + # Or set via environment variable export DUNE_API_KEY= ``` @@ -26,8 +29,8 @@ Manage and execute Dune queries. | `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 | +| `query run [--param key=value] [--performance medium\|large] [--limit] [--timeout] [--no-wait]` | Execute a saved query and display results | +| `query run-sql --sql [--param key=value] [--performance medium\|large] [--limit] [--timeout] [--no-wait]` | Execute raw SQL directly | ### `dune execution` @@ -35,7 +38,7 @@ Manage query executions. | Command | Description | |---------|-------------| -| `execution results [--limit] [--offset]` | Fetch results of a query execution | +| `execution results [--limit] [--offset] [--timeout] [--no-wait]` | Fetch results of a query execution | ### `dune dataset` @@ -44,6 +47,7 @@ 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 | +| `dataset search-by-contract --contract-address
[--blockchains] [--include-schema] [--limit] [--offset]` | Search for decoded tables by contract address | Categories: `canonical`, `decoded`, `spell`, `community` @@ -65,4 +69,4 @@ dune usage [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] ## Output Format -Most commands support `-o, --output ` with `text` (default) or `json`. +All commands (except `auth`) support `-o, --output ` with `text` (default) or `json`. diff --git a/cmd/dataset/search_by_contract_test.go b/cmd/dataset/search_by_contract_test.go new file mode 100644 index 0000000..3b74b71 --- /dev/null +++ b/cmd/dataset/search_by_contract_test.go @@ -0,0 +1,130 @@ +package dataset_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSearchByContractSuccess(t *testing.T) { + dt := "decoded" + var gotReq models.SearchDatasetsByContractAddressRequest + + mock := &mockClient{ + searchByContractAddressFn: func(req models.SearchDatasetsByContractAddressRequest) (*models.SearchDatasetsResponse, error) { + gotReq = req + return &models.SearchDatasetsResponse{ + Total: 2, + Results: []models.SearchDatasetResult{ + { + FullName: "uniswap_v3_ethereum.UniswapV3Factory_evt_PoolCreated", + Category: "decoded", + DatasetType: &dt, + Blockchains: []string{"ethereum"}, + }, + { + FullName: "uniswap_v3_ethereum.UniswapV3Factory_call_createPool", + Category: "decoded", + DatasetType: &dt, + Blockchains: []string{"ethereum"}, + }, + }, + Pagination: models.SearchDatasetsPagination{ + Limit: 20, + Offset: 0, + HasMore: false, + }, + }, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{ + "dataset", "search-by-contract", + "--contract-address", "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", + "--blockchains", "ethereum", + "--limit", "10", + }) + + require.NoError(t, root.Execute()) + + // Verify flags mapped to request + assert.Equal(t, "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", gotReq.ContractAddress) + assert.Equal(t, []string{"ethereum"}, gotReq.Blockchains) + assert.Equal(t, int32(10), *gotReq.Limit) + + // Verify table output + out := buf.String() + assert.Contains(t, out, "uniswap_v3_ethereum.UniswapV3Factory_evt_PoolCreated") + assert.Contains(t, out, "uniswap_v3_ethereum.UniswapV3Factory_call_createPool") + assert.Contains(t, out, "decoded") + assert.Contains(t, out, "2 of 2 results") +} + +func TestSearchByContractJSON(t *testing.T) { + mock := &mockClient{ + searchByContractAddressFn: func(req models.SearchDatasetsByContractAddressRequest) (*models.SearchDatasetsResponse, error) { + return &models.SearchDatasetsResponse{ + Total: 1, + Results: []models.SearchDatasetResult{ + { + FullName: "uniswap_v3_ethereum.UniswapV3Factory_evt_PoolCreated", + Category: "decoded", + }, + }, + Pagination: models.SearchDatasetsPagination{ + Limit: 20, + Offset: 0, + HasMore: false, + }, + }, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{ + "dataset", "search-by-contract", + "--contract-address", "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", + "-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, "uniswap_v3_ethereum.UniswapV3Factory_evt_PoolCreated", resp.Results[0].FullName) +} + +func TestSearchByContractError(t *testing.T) { + mock := &mockClient{ + searchByContractAddressFn: func(req models.SearchDatasetsByContractAddressRequest) (*models.SearchDatasetsResponse, error) { + return nil, fmt.Errorf("API error: unauthorized") + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{ + "dataset", "search-by-contract", + "--contract-address", "0xdeadbeef", + }) + + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "API error: unauthorized") +} + +func TestSearchByContractRequiresAddress(t *testing.T) { + mock := &mockClient{} + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"dataset", "search-by-contract"}) + + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "contract-address") +} diff --git a/cmd/dataset/search_test.go b/cmd/dataset/search_test.go index 727445b..903a551 100644 --- a/cmd/dataset/search_test.go +++ b/cmd/dataset/search_test.go @@ -18,13 +18,18 @@ import ( type mockClient struct { dune.DuneClient - searchDatasetsFn func(models.SearchDatasetsRequest) (*models.SearchDatasetsResponse, error) + searchDatasetsFn func(models.SearchDatasetsRequest) (*models.SearchDatasetsResponse, error) + searchByContractAddressFn func(models.SearchDatasetsByContractAddressRequest) (*models.SearchDatasetsResponse, error) } func (m *mockClient) SearchDatasets(req models.SearchDatasetsRequest) (*models.SearchDatasetsResponse, error) { return m.searchDatasetsFn(req) } +func (m *mockClient) SearchDatasetsByContractAddress(req models.SearchDatasetsByContractAddressRequest) (*models.SearchDatasetsResponse, error) { + return m.searchByContractAddressFn(req) +} + func newTestRoot(mock dune.DuneClient) (*cobra.Command, *bytes.Buffer) { root := &cobra.Command{ Use: "dune", diff --git a/plan/query-commands.md b/plan/query-commands.md deleted file mode 100644 index 197e3fa..0000000 --- a/plan/query-commands.md +++ /dev/null @@ -1,603 +0,0 @@ -# Dune CLI — Implementation Plan - -## Commands - -### `dune query` — query management + execution triggers - -| Command | Maps to MCP tool | SDK method | -|---------|-----------------|------------| -| `query create` | `createDuneQuery` | `CreateQuery` (new — added to SDK in Step 2) | -| `query get` | `getDuneQuery` | `GetQuery` (new — added to SDK in Step 2) | -| `query update` | `updateDuneQuery` | `UpdateQuery` (new — added to SDK in Step 2) | -| `query archive` | `updateDuneQuery` (is_archived) | `ArchiveQuery` (new — added to SDK in Step 2) | -| `query run` | `executeQueryById` + `getExecutionResults` | `RunQuery` + `Execution.WaitGetResults` | -| `query run-sql` | (ad-hoc SQL) + `getExecutionResults` | `RunSQL` + `Execution.WaitGetResults` | - -### `dune execution` — operations on executions (by execution ID) - -| Command | Maps to MCP tool | SDK method | -|---------|-----------------|------------| -| `execution results` | `getExecutionResults` | `QueryResultsV2` | - -All commands use **only** the SDK's `dune.DuneClient` interface. No separate HTTP client in the CLI. - -## Framework: Cobra + Charmbracelet Fang - -- `github.com/spf13/cobra` — CLI framework (35k+ stars, industry standard) -- `github.com/charmbracelet/fang` — styled help pages, man pages, theming (wraps Cobra) -- Entry point: `fang.Execute(context.Background(), rootCmd)` instead of raw `rootCmd.Execute()` - -## Guiding principle: Everything goes through the SDK - -All Dune API interactions go through `github.com/duneanalytics/duneapi-client-go`. Missing functionality is added to the SDK itself — the CLI has **no custom HTTP calls**. - -The SDK already provides: auth, config, HTTP utils, execution (`RunQuery`, `RunSQL`, `QueryExecute`, `SQLExecute`), results (`QueryResultsV2` with pagination), polling (`Execution.WaitGetResults`), models. - -Always reuse existing structs from the SDK's `models/` package. - -## Key dependency: duneapi-client-go - -**Local development**: The CLI's `go.mod` uses a `replace` directive to point to the local SDK checkout while changes are in development: - -``` -replace github.com/duneanalytics/duneapi-client-go => ../duneapi-client-go -``` - -This allows `import "github.com/duneanalytics/duneapi-client-go/dune"` as normal — Go resolves from the local filesystem. Remove the `replace` line once the SDK changes are merged. - -**Existing SDK methods used by the CLI (signatures updated in Step 2):** - -| Method | Signature (after Step 2) | Used by | -|--------|--------------------------|---------| -| `RunQuery` | `(queryID int, params map[string]any, performance string) (Execution, error)` | `run` (wait mode) | -| `QueryExecute` | `(queryID int, params map[string]any, performance string) (*ExecuteResponse, error)` | `run` (no-wait mode) | -| `RunSQL` | `(sql string, performance string, params map[string]any) (Execution, error)` | `run-sql` | -| `SQLExecute` | `(sql string, performance string, params map[string]any) (*ExecuteResponse, error)` | (available) | -| `Execution.WaitGetResults` | `(pollInterval time.Duration, maxRetries int) (*ResultsResponse, error)` | `run`, `run-sql` | -| `QueryResultsV2` | `(executionID string, options ResultOptions) (*ResultsResponse, error)` | `results` | -| `QueryStatus` | `(executionID string) (*StatusResponse, error)` | (optional) | - -**New SDK methods added in Step 2:** - -| Method | Signature | Used by | -|--------|-----------|---------| -| `CreateQuery` | `(req CreateQueryRequest) (*CreateQueryResponse, error)` | `create` | -| `GetQuery` | `(queryID int) (*GetQueryResponse, error)` | `get` | -| `UpdateQuery` | `(queryID int, req UpdateQueryRequest) (*UpdateQueryResponse, error)` | `update` | -| `ArchiveQuery` | `(queryID int) (*UpdateQueryResponse, error)` | `archive` | - -**Existing SDK models fixed in Step 2:** - -| Model | Field added | Why | -|-------|-------------|-----| -| `ExecuteRequest` | `Performance string` | API accepts `performance` in execute body; needed for `--performance` flag | -| `ExecuteSQLRequest` | `QueryParameters map[string]any` | API accepts params in SQL execute body; needed for `--param` flag | - -## Architecture: Single SDK client in context - -```go -// Stored in Cobra command context, accessed via ClientFromCmd(cmd) -dune.DuneClient -``` - -One client, created from `*config.Env` in `PersistentPreRunE`. No wrapper structs needed. - ---- - -## Step 1: Project Scaffolding + SDK Integration - -- [x] Done - -Add `github.com/spf13/cobra`, `github.com/charmbracelet/fang`, and `github.com/duneanalytics/duneapi-client-go` deps. Create root command (`cli/root.go`) with persistent `--api-key` flag (overrides `DUNE_API_KEY` env). Create `query` parent command (`cmd/query/query.go`). Use `fang.Execute(context.Background(), rootCmd)`. - -**SDK integration:** -- Delete local `config/` package — use SDK's `config` package instead (identical API: `FromEnvVars()`, `FromAPIKey()`, `Env{APIKey, Host}`) -- Delete local `models/error.go` — use SDK error patterns -- In `PersistentPreRunE`: build `*config.Env` from SDK, create `dune.NewDuneClient(env)`, store in context -- Add `replace` directive to `go.mod` pointing to `../duneapi-client-go` -- Provide `cmdutil.ClientFromCmd(cmd) dune.DuneClient` helper - -File structure: `cmd/main.go`, `cli/root.go`, `cmdutil/client.go`, `cmd/query/query.go`. - -Reuses: `config.Env`, `config.FromEnvVars()`, `config.FromAPIKey()`, `dune.NewDuneClient(env)`. - -**Acceptance criteria:** -- `dune --help` lists `query` as subcommand with Fang-styled help -- `dune query --help` lists available subcommands -- Missing API key prints error to stderr, exits 1 -- `make build` produces binary -- No local `config/` or `models/` packages remain -- `go vet ./...` passes - -**Tests:** -- Root command initializes without error -- Missing API key returns error -- `ClientFromCmd` returns non-nil DuneClient when API key is set -- Query command registered as subcommand - ---- - -## Step 1b: `dune auth` — persistent API key config - -- [x] Done - -Adds `dune auth` command and persistent config file at `~/.config/dune/config.yaml`. - -### API key priority (all commands) - -1. `--api-key` flag -2. `DUNE_API_KEY` env var -3. `~/.config/dune/config.yaml` -4. Error: `"missing API key: set DUNE_API_KEY, pass --api-key, or run dune auth"` - -### New package: `authconfig/` - -- `Config` struct with `APIKey` field (YAML: `api_key`) -- `Dir()` → `$HOME/.config/dune` -- `Path()` → `Dir() + /config.yaml` -- `Load()` → reads/parses; returns `nil, nil` if file missing -- `Save()` → creates dir (0700) + file (0600) -- `LoadAPIKey()` → convenience; returns `""` on any error -- `SetDirFunc`/`ResetDirFunc` for test isolation - -### New command: `cmd/auth/auth.go` - -``` -dune auth [--api-key KEY] -``` - -Reads key from: flag → env var → interactive prompt. Saves to config file. - -### Changes to `cli/root.go` - -- `PersistentPreRunE` skips client setup for `auth` command -- Falls back to `authconfig.LoadAPIKey()` when flag and env var are both missing -- Error message updated to mention `dune auth` - ---- - -## Step 2: Add Query CRUD to SDK - -- [ ] Done - -**This step modifies the SDK repo** at `/Users/ivpusic/github/dune/duneapi-client-go`, not the CLI. - -Add query CRUD endpoints to the `DuneClient` interface and fix incomplete execution models. Verified against API server source (`duneapi/models/querycrud.go`) and docs (`docs.dune.com/api-reference/queries`). - -### Updated file: `models/execute.go` — fix incomplete models - -```go -type ExecuteRequest struct { - QueryParameters map[string]any `json:"query_parameters,omitempty"` - Performance string `json:"performance,omitempty"` // NEW — "medium" or "large" -} - -type ExecuteSQLRequest struct { - SQL string `json:"sql"` - Performance string `json:"performance,omitempty"` - QueryParameters map[string]any `json:"query_parameters,omitempty"` // NEW — parameterized SQL -} -``` - -Also update `SQLExecute` and `RunSQL` signatures to accept `queryParameters`: - -```go -// Current: SQLExecute(sql string, performance string) -// Updated: SQLExecute(sql string, performance string, queryParameters map[string]any) -// Current: RunSQL(sql string, performance string) -// Updated: RunSQL(sql string, performance string, queryParameters map[string]any) -``` - -And update `QueryExecute` to pass `Performance` through: - -```go -// The existing QueryExecute already takes queryParameters map[string]any. -// Just need to populate ExecuteRequest.Performance from the request. -// Add performance parameter to signature: -// Current: QueryExecute(queryID int, queryParameters map[string]any) -// Updated: QueryExecute(queryID int, queryParameters map[string]any, performance string) -// Similarly for RunQuery: -// Current: RunQuery(queryID int, queryParameters map[string]any) -// Updated: RunQuery(queryID int, queryParameters map[string]any, performance string) -``` - -### New file: `models/query.go` - -Types match the Dune API spec (reference: `duneapi/models/querycrud.go`, docs: `docs.dune.com/api-reference/queries`). - -```go -// QueryParameter represents a parameterized query variable. -// Supported types: "text", "number", "datetime", "enum". -type QueryParameter struct { - Key string `json:"key"` - Type string `json:"type"` - Value string `json:"value"` - EnumOptions []string `json:"enumOptions,omitempty"` -} - -// POST /api/v1/query -type CreateQueryRequest struct { - Name string `json:"name"` - QuerySQL string `json:"query_sql"` - Description string `json:"description,omitempty"` - IsPrivate bool `json:"is_private,omitempty"` - Parameters []QueryParameter `json:"parameters,omitempty"` - Tags []string `json:"tags,omitempty"` -} - -type CreateQueryResponse struct { - QueryID int `json:"query_id"` -} - -// GET /api/v1/query/{queryId} -type GetQueryResponse struct { - QueryID int `json:"query_id"` - Name string `json:"name"` - Description string `json:"description"` - Tags []string `json:"tags"` - Version int `json:"version"` - Parameters []QueryParameter `json:"parameters"` - QueryEngine string `json:"query_engine"` - QuerySQL string `json:"query_sql"` - IsPrivate bool `json:"is_private"` - IsArchived bool `json:"is_archived"` - IsUnsaved bool `json:"is_unsaved"` - Owner string `json:"owner"` -} - -// PATCH /api/v1/query/{queryId} -// Pointer fields with omitempty — only non-nil fields are serialized. -type UpdateQueryRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - QuerySQL *string `json:"query_sql,omitempty"` - Tags *[]string `json:"tags,omitempty"` - Parameters *[]QueryParameter `json:"parameters,omitempty"` - IsPrivate *bool `json:"is_private,omitempty"` - IsArchived *bool `json:"is_archived,omitempty"` -} - -type UpdateQueryResponse struct { - QueryID int `json:"query_id"` -} -``` - -### New file: `dune/query.go` - -```go -// POST /api/v1/query -func (c *duneClient) CreateQuery(req models.CreateQueryRequest) (*models.CreateQueryResponse, error) - -// GET /api/v1/query/{queryId} -func (c *duneClient) GetQuery(queryID int) (*models.GetQueryResponse, error) - -// PATCH /api/v1/query/{queryId} -func (c *duneClient) UpdateQuery(queryID int, req models.UpdateQueryRequest) (*models.UpdateQueryResponse, error) - -// POST /api/v1/query/{queryId}/archive -func (c *duneClient) ArchiveQuery(queryID int) (*models.UpdateQueryResponse, error) -``` - -Uses existing `httpRequest()` and `decodeBody()` helpers from `dune/http.go`. Follows the same pattern as `QueryExecute` / `SQLExecute`. - -### Updated file: `dune/dune.go` - -Add to `DuneClient` interface: - -```go -// Query CRUD -CreateQuery(req models.CreateQueryRequest) (*models.CreateQueryResponse, error) -GetQuery(queryID int) (*models.GetQueryResponse, error) -UpdateQuery(queryID int, req models.UpdateQueryRequest) (*models.UpdateQueryResponse, error) -ArchiveQuery(queryID int) (*models.UpdateQueryResponse, error) -``` - -Update existing method signatures: - -```go -// Updated signatures (add performance/params where missing): -QueryExecute(queryID int, queryParameters map[string]any, performance string) (*models.ExecuteResponse, error) -RunQuery(queryID int, queryParameters map[string]any, performance string) (Execution, error) -RunQueryGetRows(queryID int, queryParameters map[string]any, performance string) ([]map[string]any, error) -SQLExecute(sql string, performance string, queryParameters map[string]any) (*models.ExecuteResponse, error) -RunSQL(sql string, performance string, queryParameters map[string]any) (Execution, error) -``` - -Add URL templates: - -```go -createQueryURLTemplate = "%s/api/v1/query" -queryURLTemplate = "%s/api/v1/query/%d" // GET + PATCH -archiveQueryURLTemplate = "%s/api/v1/query/%d/archive" // POST -``` - -**Acceptance criteria:** -- `CreateQuery` POSTs to `/api/v1/query` with JSON body containing name, query_sql, description, is_private, parameters, tags -- `GetQuery` GETs `/api/v1/query/{id}`, parses full response including version, query_engine, is_unsaved, owner -- `UpdateQuery` PATCHes `/api/v1/query/{id}` with only non-nil fields in body -- `ArchiveQuery` POSTs to `/api/v1/query/{id}/archive` with empty body -- `ExecuteRequest` now includes `Performance` field in JSON body -- `ExecuteSQLRequest` now includes `QueryParameters` field in JSON body -- `QueryExecute` / `RunQuery` accept and pass `performance` parameter -- `SQLExecute` / `RunSQL` accept and pass `queryParameters` parameter -- All methods use existing `httpRequest` helper (sets `X-DUNE-API-KEY` header) -- Non-2xx responses follow existing SDK error pattern (`ErrorReqUnsuccessful`) -- Existing SDK tests still pass (`go test ./...`) - -**Tests (new file `dune/query_test.go`, using httptest):** -- CreateQuery: verify POST method, path `/api/v1/query`, request body fields; parse `query_id` response -- GetQuery: verify GET method, path `/api/v1/query/123`; parse all response fields -- UpdateQuery: verify PATCH method, path `/api/v1/query/123`; body omits nil fields, includes non-nil fields -- ArchiveQuery: verify POST method, path `/api/v1/query/123/archive`; parse `query_id` response -- Error responses (400, 401, 404) return `ErrorReqUnsuccessful` - -**Tests (update existing execution tests):** -- `QueryExecute` with performance: verify `performance` field in request body -- `SQLExecute` with queryParameters: verify `query_parameters` field in request body - ---- - -## Step 3: Output Formatting - -- [x] Deferred — create `output/` inline when the first command needs it (Step 4). - ---- - -## Step 4: `dune query create` - -- [x] Done - -`cmd/query/create.go` — flags: `--name` (required), `--sql` (required), `--description`, `--private`, `-o`. Gets client via `cmdutil.ClientFromCmd(cmd)`, calls `client.CreateQuery(models.CreateQueryRequest{...})`. - -API reference: POST `/api/v1/query` — name (max 600 chars), query_sql (max 500k chars), description (max 1k chars), is_private, parameters, tags → `{"query_id": int}`. - -**Output:** text: `Created query 4125432` / json: `{"query_id": 4125432}` - -**Acceptance criteria:** -- `dune query create --name "Test" --sql "SELECT 1"` creates query, prints ID -- `--private` sets is_private=true -- Missing `--name` or `--sql` errors -- API error printed to stderr, exits 1 -- `-o json` works - -**Tests:** -- Required flags validation -- Successful create prints ID (mock DuneClient) -- Private flag passed correctly -- JSON output format - ---- - -## Step 5: `dune query get` - -- [ ] Done - -`cmd/query/get.go` — positional arg: query ID (required, integer). Flag: `-o`. Calls `client.GetQuery(queryID)`. - -API reference: GET `/api/v1/query/{queryId}` → query_id, name, description, query_sql, owner, is_private, is_archived, is_unsaved, version, query_engine, tags, parameters. - -**Output:** text: key-value with SQL block / json: full response. - -**Acceptance criteria:** -- `dune query get 4125432` displays metadata + SQL -- Missing/non-integer ID errors -- 404 prints clear error -- `-o json` outputs full response - -**Tests:** -- Valid ID renders text output (mock DuneClient) -- JSON output matches response -- Missing argument errors -- Non-integer argument errors -- 404 handled - ---- - -## Step 6: `dune query update` - -- [ ] Done - -`cmd/query/update.go` — positional arg: query ID. Flags: `--name`, `--sql`, `--description`, `--private`, `--tags` — all optional but at least one required. Only sends provided fields (pointer/omitempty pattern). Calls `client.UpdateQuery(queryID, models.UpdateQueryRequest{...})`. - -API reference: PATCH `/api/v1/query/{queryId}` — name, query_sql, description, parameters, tags, is_private, is_archived (all optional) → `{"query_id": int}`. - -**Output:** text: `Updated query 4125432` / json: `{"query_id": 4125432}` - -**Acceptance criteria:** -- Single flag update sends only that field -- Multiple flags all included -- No flags prints error -- API errors (404, 409) printed clearly - -**Tests:** -- Single flag → only that field in PATCH body -- Multiple flags all present -- No flags → usage error -- 404 handled - ---- - -## Step 7: `dune query archive` - -- [ ] Done - -`cmd/query/archive.go` — positional arg: query ID. Calls `client.ArchiveQuery(queryID)`. - -API reference: POST `/api/v1/query/{queryId}/archive` — dedicated endpoint, no request body → `{"query_id": int}`. - -**Output:** `Archived query 4125432` - -**Acceptance criteria:** -- Sends POST to `/api/v1/query/{id}/archive` -- Missing ID errors -- API errors handled - -**Tests:** -- Correct HTTP method and path (mock DuneClient) -- Missing argument errors -- 404 handled - ---- - -## Step 8: `dune query run` - -- [x] Done - -`cmd/query/run.go` — positional arg: query ID. Flags: `--param key=value` (repeatable), `--performance medium|large`, `--limit`, `--no-wait`, `-o`. - -**SDK-first approach — no custom polling logic:** - -- `--no-wait` mode: calls `client.QueryExecute(queryID, params, performance)`, prints execution ID, exits -- Wait mode (default): calls `client.RunQuery(queryID, params, performance)` → `exec.WaitGetResults(pollInterval, maxRetries)` — SDK handles all polling internally -- `--performance` flag: passed directly to SDK methods (Step 2 adds `Performance` field to `ExecuteRequest`) - -API reference: POST `/api/v1/query/{query_id}/execute` — body: `{"query_parameters": {...}, "performance": "medium"|"large"}`. Response: `{"execution_id": string, "state": string}`. - -No `poll.go` needed — the SDK's `Execution.WaitGetResults()` replaces all custom polling logic. - -Reuses: SDK's `RunQuery`, `Execution.WaitGetResults`, `QueryExecute`, `ResultsResponse`. - -**Output:** `--no-wait`: `Execution ID: 01JG...` / table: rows + footer with row count / json: full result object. - -**Acceptance criteria:** -- Executes and prints results as table -- `--param` flags parsed and passed as `map[string]any` -- `--performance large` passed to SDK's `QueryExecute`/`RunQuery` -- `--limit` limits displayed rows -- `--no-wait` prints execution ID only -- Failed execution prints error, exits 1 -- Progress shown on stderr during polling (SDK handles this) -- `-o json` works - -**Tests:** -- Param parsing ("key=value" → map) -- Performance flag passed to SDK methods -- No-wait mode returns execution ID -- Successful execution renders table (mock DuneClient interface) -- Failed execution prints error, exits 1 -- JSON output format - ---- - -## Step 9: `dune execution results` - -- [x] Done - -New `execution` parent command (`cmd/execution/execution.go`) + `results` subcommand (`cmd/execution/results.go`). - -Positional arg: execution ID (string). Flags: `--limit`, `--offset`, `-o`. - -One-shot fetch via `client.QueryResultsV2(executionID, models.ResultOptions{Page: &models.ResultPageOption{Offset, Limit}})` — no polling. If still running: print status, exit 0. If complete: display results. If failed: print error, exit 1. - -API reference: GET `/api/v1/execution/{execution_id}/results` — query params: limit, offset → state, result metadata, data rows. States: `QUERY_STATE_COMPLETED`, `QUERY_STATE_PENDING`, `QUERY_STATE_EXECUTING`, `QUERY_STATE_FAILED`, `QUERY_STATE_CANCELLED`, `QUERY_STATE_EXPIRED`. - -Reuses: SDK's `QueryResultsV2`, `models.ResultOptions`, `models.ResultPageOption`, `models.ResultsResponse`. - -Reuses from `cmd/query/run.go`: `displayResults` and `resultRowsToStrings` — these must be moved to a shared location (e.g., `output/` package or `cmdutil/`) since they'll now be called from a different package. - -**Acceptance criteria:** -- `dune execution results ` displays results -- `--limit` and `--offset` work (passed to `ResultPageOption`) -- Running execution prints status, exits 0 -- Failed execution prints error, exits 1 -- `-o json` works - -**Tests:** -- Completed execution renders results (mock DuneClient) -- Running execution prints status -- Failed execution prints error -- Offset and limit passed correctly -- Missing argument errors - ---- - -## Step 10: `dune query run-sql` - -- [ ] Done - -`cmd/query/run_sql.go` — flags: `--sql` (required), `--performance medium|large`, `--limit`, `--param key=value`, `-o`. - -Calls `client.RunSQL(sql, performance, params)` which returns an `Execution` interface, then `exec.WaitGetResults(pollInterval, maxRetries)`. Fully SDK-driven — the SDK accepts `performance` and `queryParameters` (Step 2 fixes `ExecuteSQLRequest` to include both). - -API reference: POST `/api/v1/sql/execute` — body: `{"sql": string, "performance": string, "query_parameters": {...}}` → `{"execution_id": string, "state": string}`. - -Reuses: SDK's `RunSQL`, `Execution.WaitGetResults`, `ResultsResponse`. - -**Acceptance criteria:** -- `dune query run-sql --sql "SELECT 1"` executes and prints results -- `--performance large` passed to SDK's `RunSQL` -- `--param key=value` passed as `queryParameters` to SDK's `RunSQL` -- `--limit` limits displayed rows -- Missing `--sql` errors -- SQL syntax error prints error with hint -- Progress shown on stderr - -**Tests:** -- Successful execution and display (mock DuneClient) -- Missing --sql flag errors -- Performance flag passed to `RunSQL` -- Param flags passed as queryParameters to `RunSQL` -- SQL error prints details -- Uses SDK Execution for polling (same pattern as `run`) - ---- - -## File Structure - -``` -cli/ # CLI repo - cli/ - root.go # Step 1: root command, Execute() - cmd/ - main.go # Entry point (exists) - auth/ - auth.go # `dune auth` command - auth_test.go # Auth command tests - query/ - query.go # Query parent command (exists) - helpers.go # Shared helpers (parseQueryID) - create.go # Step 4 - get.go # Step 5 - update.go # Step 6 - archive.go # Step 7 - run.go # Step 8 - run_sql.go # Step 10 - execution/ - execution.go # Step 9: Execution parent command - results.go # Step 9: Results subcommand - authconfig/ - authconfig.go # Config struct, Dir, Path, Load, Save, LoadAPIKey - authconfig_test.go # Tests for save/load/missing/malformed - cmdutil/ - client.go # SetClient, ClientFromCmd (context helpers) - output/ - output.go # Shared output formatting (text, JSON) - results.go # Step 9: Shared result display (moved from cmd/query/run.go) - go.mod # Has replace directive → ../duneapi-client-go - plan/ - query-commands.md # This plan - -duneapi-client-go/ # SDK repo (separate) - models/ - query.go # Step 2: new — query CRUD types - execute.go # Step 2: updated — add Performance to ExecuteRequest, - # QueryParameters to ExecuteSQLRequest - dune/ - dune.go # Step 2: updated — 4 new methods + updated signatures - query.go # Step 2: new — CreateQuery, GetQuery, UpdateQuery, ArchiveQuery - query_test.go # Step 2: new — tests -``` - -## Dependency Graph - -``` -Step 1 (scaffolding + SDK integration + replace directive) - ├── Step 2 (add query CRUD to SDK — separate repo) - │ └── Steps 4-7 (CRUD commands — need Step 2) - ├── Step 8 (query run — need Step 1, SDK already has methods) - ├── Step 9 (execution results — need Step 1, new execution namespace) - └── Step 10 (query run-sql — need Step 1, SDK already has RunSQL) -``` - -Output formatting (`output/`) is created inline with the first command that needs it. -Steps 4-7 depend on Step 2. Steps 8-10 only need Step 1 (SDK already has execution methods).