Skip to content

Commit eccbb4b

Browse files
committed
Add sim command foundation: auth config, HTTP client, parent commands, root registration
- Extend authconfig.Config with SimAPIKey field for sim_api_key in config.yaml - Add SimClient HTTP wrapper (cmd/sim/client.go) with X-Sim-Api-Key auth header and structured HTTP error handling (400/401/404/429/500) - Add SetSimClient/SimClientFromCmd to cmdutil for context-based client injection - Create sim parent command with PersistentPreRunE that resolves sim API key (--sim-api-key flag > DUNE_SIM_API_KEY env > config file) - Create stub evm/svm parent commands for future subcommands - Register sim command in cli/root.go init() - All commands annotated with skipAuth to bypass Dune API key requirement
1 parent a5637f4 commit eccbb4b

7 files changed

Lines changed: 263 additions & 0 deletions

File tree

authconfig/authconfig.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
// Config holds the persisted CLI configuration.
1414
type Config struct {
1515
APIKey string `yaml:"api_key"`
16+
SimAPIKey string `yaml:"sim_api_key,omitempty"`
1617
Telemetry *bool `yaml:"telemetry,omitempty"`
1718
}
1819

cli/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/duneanalytics/cli/cmd/docs"
2121
"github.com/duneanalytics/cli/cmd/execution"
2222
"github.com/duneanalytics/cli/cmd/query"
23+
"github.com/duneanalytics/cli/cmd/sim"
2324
"github.com/duneanalytics/cli/cmd/usage"
2425
"github.com/duneanalytics/cli/cmdutil"
2526
"github.com/duneanalytics/cli/tracking"
@@ -106,6 +107,7 @@ func init() {
106107
rootCmd.AddCommand(query.NewQueryCmd())
107108
rootCmd.AddCommand(execution.NewExecutionCmd())
108109
rootCmd.AddCommand(usage.NewUsageCmd())
110+
rootCmd.AddCommand(sim.NewSimCmd())
109111
}
110112

111113
// Execute runs the root command via Fang.

cmd/sim/client.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package sim
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"time"
11+
)
12+
13+
const defaultBaseURL = "https://api.sim.dune.com"
14+
15+
// SimClient is a lightweight HTTP client for the Sim API.
16+
type SimClient struct {
17+
baseURL string
18+
apiKey string
19+
httpClient *http.Client
20+
}
21+
22+
// NewSimClient creates a new Sim API client with the given API key.
23+
func NewSimClient(apiKey string) *SimClient {
24+
return &SimClient{
25+
baseURL: defaultBaseURL,
26+
apiKey: apiKey,
27+
httpClient: &http.Client{
28+
Timeout: 30 * time.Second,
29+
},
30+
}
31+
}
32+
33+
// Get performs a GET request to the Sim API and returns the raw JSON response body.
34+
// The path should include the leading slash (e.g. "/v1/evm/supported-chains").
35+
// Query parameters are appended from params.
36+
func (c *SimClient) Get(ctx context.Context, path string, params url.Values) ([]byte, error) {
37+
u, err := url.Parse(c.baseURL + path)
38+
if err != nil {
39+
return nil, fmt.Errorf("invalid URL: %w", err)
40+
}
41+
if params != nil {
42+
u.RawQuery = params.Encode()
43+
}
44+
45+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
46+
if err != nil {
47+
return nil, fmt.Errorf("creating request: %w", err)
48+
}
49+
req.Header.Set("X-Sim-Api-Key", c.apiKey)
50+
req.Header.Set("Accept", "application/json")
51+
52+
resp, err := c.httpClient.Do(req)
53+
if err != nil {
54+
return nil, fmt.Errorf("request failed: %w", err)
55+
}
56+
defer resp.Body.Close()
57+
58+
body, err := io.ReadAll(resp.Body)
59+
if err != nil {
60+
return nil, fmt.Errorf("reading response: %w", err)
61+
}
62+
63+
if resp.StatusCode >= 400 {
64+
return nil, httpError(resp.StatusCode, body)
65+
}
66+
67+
return body, nil
68+
}
69+
70+
// httpError returns a user-friendly error for HTTP error status codes.
71+
func httpError(status int, body []byte) error {
72+
// Try to extract a message from the JSON error response.
73+
var errResp struct {
74+
Error string `json:"error"`
75+
Message string `json:"message"`
76+
}
77+
msg := ""
78+
if json.Unmarshal(body, &errResp) == nil {
79+
if errResp.Error != "" {
80+
msg = errResp.Error
81+
} else if errResp.Message != "" {
82+
msg = errResp.Message
83+
}
84+
}
85+
86+
switch status {
87+
case http.StatusBadRequest:
88+
if msg != "" {
89+
return fmt.Errorf("bad request: %s", msg)
90+
}
91+
return fmt.Errorf("bad request")
92+
case http.StatusUnauthorized:
93+
return fmt.Errorf("authentication failed: check your Sim API key")
94+
case http.StatusNotFound:
95+
if msg != "" {
96+
return fmt.Errorf("not found: %s", msg)
97+
}
98+
return fmt.Errorf("not found")
99+
case http.StatusTooManyRequests:
100+
return fmt.Errorf("rate limit exceeded: try again later")
101+
default:
102+
if status >= 500 {
103+
return fmt.Errorf("Sim API server error (HTTP %d): try again later", status)
104+
}
105+
if msg != "" {
106+
return fmt.Errorf("Sim API error (HTTP %d): %s", status, msg)
107+
}
108+
return fmt.Errorf("Sim API error (HTTP %d)", status)
109+
}
110+
}

cmd/sim/evm/evm.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package evm
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
// NewEvmCmd returns the `sim evm` parent command.
8+
func NewEvmCmd() *cobra.Command {
9+
cmd := &cobra.Command{
10+
Use: "evm",
11+
Short: "Query EVM chain data (balances, activity, transactions, etc.)",
12+
Long: "Access real-time EVM blockchain data including token balances, activity feeds,\n" +
13+
"transaction history, NFT collectibles, token metadata, token holders,\n" +
14+
"and DeFi positions.",
15+
}
16+
17+
// Subcommands will be added here as they are implemented.
18+
19+
return cmd
20+
}

cmd/sim/sim.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package sim
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/duneanalytics/cli/authconfig"
11+
"github.com/duneanalytics/cli/cmd/sim/evm"
12+
"github.com/duneanalytics/cli/cmd/sim/svm"
13+
"github.com/duneanalytics/cli/cmdutil"
14+
)
15+
16+
var simAPIKeyFlag string
17+
18+
// NewSimCmd returns the `sim` parent command.
19+
func NewSimCmd() *cobra.Command {
20+
cmd := &cobra.Command{
21+
Use: "sim",
22+
Short: "Query real-time blockchain data via the Sim API",
23+
Long: "Access real-time blockchain data including balances, activity, transactions,\n" +
24+
"collectibles, token info, token holders, and DeFi positions across EVM and SVM chains.\n\n" +
25+
"Authenticate with a Sim API key via --sim-api-key, the DUNE_SIM_API_KEY environment\n" +
26+
"variable, or by running `dune sim auth`.",
27+
Annotations: map[string]string{"skipAuth": "true"},
28+
PersistentPreRunE: simPreRun,
29+
}
30+
31+
cmd.PersistentFlags().StringVar(
32+
&simAPIKeyFlag, "sim-api-key", "",
33+
"Sim API key (overrides DUNE_SIM_API_KEY env var)",
34+
)
35+
36+
cmd.AddCommand(evm.NewEvmCmd())
37+
cmd.AddCommand(svm.NewSvmCmd())
38+
39+
return cmd
40+
}
41+
42+
// simPreRun resolves the Sim API key and stores a SimClient in the command context.
43+
// Commands annotated with "skipSimAuth": "true" bypass this step.
44+
func simPreRun(cmd *cobra.Command, _ []string) error {
45+
// Allow commands like `sim auth` to skip sim client creation.
46+
if cmd.Annotations["skipSimAuth"] == "true" {
47+
return nil
48+
}
49+
50+
apiKey := resolveSimAPIKey()
51+
if apiKey == "" {
52+
return fmt.Errorf(
53+
"missing Sim API key: set DUNE_SIM_API_KEY, pass --sim-api-key, or run `dune sim auth`",
54+
)
55+
}
56+
57+
client := NewSimClient(apiKey)
58+
cmdutil.SetSimClient(cmd, client)
59+
60+
return nil
61+
}
62+
63+
// resolveSimAPIKey resolves the Sim API key from (in priority order):
64+
// 1. --sim-api-key flag
65+
// 2. DUNE_SIM_API_KEY environment variable
66+
// 3. sim_api_key from ~/.config/dune/config.yaml
67+
func resolveSimAPIKey() string {
68+
// 1. Flag
69+
if simAPIKeyFlag != "" {
70+
return strings.TrimSpace(simAPIKeyFlag)
71+
}
72+
73+
// 2. Environment variable
74+
if key := os.Getenv("DUNE_SIM_API_KEY"); key != "" {
75+
return strings.TrimSpace(key)
76+
}
77+
78+
// 3. Config file
79+
cfg, err := authconfig.Load()
80+
if err != nil || cfg == nil {
81+
return ""
82+
}
83+
return strings.TrimSpace(cfg.SimAPIKey)
84+
}
85+
86+
// SimClientFromCmd is a convenience helper that extracts and type-asserts the
87+
// SimClient from the command context.
88+
func SimClientFromCmd(cmd *cobra.Command) *SimClient {
89+
v := cmdutil.SimClientFromCmd(cmd)
90+
if v == nil {
91+
return nil
92+
}
93+
return v.(*SimClient)
94+
}

cmd/sim/svm/svm.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package svm
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
// NewSvmCmd returns the `sim svm` parent command.
8+
func NewSvmCmd() *cobra.Command {
9+
cmd := &cobra.Command{
10+
Use: "svm",
11+
Short: "Query SVM chain data (balances, transactions)",
12+
Long: "Access real-time SVM blockchain data including token balances and\n" +
13+
"transaction history for Solana and Eclipse chains.",
14+
}
15+
16+
// Subcommands will be added here as they are implemented.
17+
18+
return cmd
19+
}

cmdutil/client.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
)
1111

1212
type clientKey struct{}
13+
type simClientKey struct{}
1314
type trackerKey struct{}
1415
type startTimeKey struct{}
1516

@@ -27,6 +28,22 @@ func ClientFromCmd(cmd *cobra.Command) dune.DuneClient {
2728
return cmd.Context().Value(clientKey{}).(dune.DuneClient)
2829
}
2930

31+
// SetSimClient stores a Sim API client in the command's context.
32+
// The value is stored as any to avoid a circular import with cmd/sim.
33+
func SetSimClient(cmd *cobra.Command, client any) {
34+
ctx := cmd.Context()
35+
if ctx == nil {
36+
ctx = context.Background()
37+
}
38+
cmd.SetContext(context.WithValue(ctx, simClientKey{}, client))
39+
}
40+
41+
// SimClientFromCmd extracts the Sim API client stored in the command's context.
42+
// Callers should type-assert the result to *sim.SimClient.
43+
func SimClientFromCmd(cmd *cobra.Command) any {
44+
return cmd.Context().Value(simClientKey{})
45+
}
46+
3047
// SetTracker stores a Tracker in the command's context.
3148
func SetTracker(cmd *cobra.Command, t *tracking.Tracker) {
3249
ctx := cmd.Context()

0 commit comments

Comments
 (0)