From eccbb4bd1fab6e03e623db6c8ba7bb847ea1715a Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:27:01 +0100 Subject: [PATCH 1/2] 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 --- authconfig/authconfig.go | 1 + cli/root.go | 2 + cmd/sim/client.go | 110 +++++++++++++++++++++++++++++++++++++++ cmd/sim/evm/evm.go | 20 +++++++ cmd/sim/sim.go | 94 +++++++++++++++++++++++++++++++++ cmd/sim/svm/svm.go | 19 +++++++ cmdutil/client.go | 17 ++++++ 7 files changed, 263 insertions(+) create mode 100644 cmd/sim/client.go create mode 100644 cmd/sim/evm/evm.go create mode 100644 cmd/sim/sim.go create mode 100644 cmd/sim/svm/svm.go diff --git a/authconfig/authconfig.go b/authconfig/authconfig.go index 640f753..c8857bd 100644 --- a/authconfig/authconfig.go +++ b/authconfig/authconfig.go @@ -13,6 +13,7 @@ import ( // Config holds the persisted CLI configuration. type Config struct { APIKey string `yaml:"api_key"` + SimAPIKey string `yaml:"sim_api_key,omitempty"` Telemetry *bool `yaml:"telemetry,omitempty"` } diff --git a/cli/root.go b/cli/root.go index 8a665bb..be3f526 100644 --- a/cli/root.go +++ b/cli/root.go @@ -20,6 +20,7 @@ import ( "github.com/duneanalytics/cli/cmd/docs" "github.com/duneanalytics/cli/cmd/execution" "github.com/duneanalytics/cli/cmd/query" + "github.com/duneanalytics/cli/cmd/sim" "github.com/duneanalytics/cli/cmd/usage" "github.com/duneanalytics/cli/cmdutil" "github.com/duneanalytics/cli/tracking" @@ -106,6 +107,7 @@ func init() { rootCmd.AddCommand(query.NewQueryCmd()) rootCmd.AddCommand(execution.NewExecutionCmd()) rootCmd.AddCommand(usage.NewUsageCmd()) + rootCmd.AddCommand(sim.NewSimCmd()) } // Execute runs the root command via Fang. diff --git a/cmd/sim/client.go b/cmd/sim/client.go new file mode 100644 index 0000000..540da40 --- /dev/null +++ b/cmd/sim/client.go @@ -0,0 +1,110 @@ +package sim + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const defaultBaseURL = "https://api.sim.dune.com" + +// SimClient is a lightweight HTTP client for the Sim API. +type SimClient struct { + baseURL string + apiKey string + httpClient *http.Client +} + +// NewSimClient creates a new Sim API client with the given API key. +func NewSimClient(apiKey string) *SimClient { + return &SimClient{ + baseURL: defaultBaseURL, + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Get performs a GET request to the Sim API and returns the raw JSON response body. +// The path should include the leading slash (e.g. "/v1/evm/supported-chains"). +// Query parameters are appended from params. +func (c *SimClient) Get(ctx context.Context, path string, params url.Values) ([]byte, error) { + u, err := url.Parse(c.baseURL + path) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + if params != nil { + u.RawQuery = params.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("X-Sim-Api-Key", c.apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, httpError(resp.StatusCode, body) + } + + return body, nil +} + +// httpError returns a user-friendly error for HTTP error status codes. +func httpError(status int, body []byte) error { + // Try to extract a message from the JSON error response. + var errResp struct { + Error string `json:"error"` + Message string `json:"message"` + } + msg := "" + if json.Unmarshal(body, &errResp) == nil { + if errResp.Error != "" { + msg = errResp.Error + } else if errResp.Message != "" { + msg = errResp.Message + } + } + + switch status { + case http.StatusBadRequest: + if msg != "" { + return fmt.Errorf("bad request: %s", msg) + } + return fmt.Errorf("bad request") + case http.StatusUnauthorized: + return fmt.Errorf("authentication failed: check your Sim API key") + case http.StatusNotFound: + if msg != "" { + return fmt.Errorf("not found: %s", msg) + } + return fmt.Errorf("not found") + case http.StatusTooManyRequests: + return fmt.Errorf("rate limit exceeded: try again later") + default: + if status >= 500 { + return fmt.Errorf("Sim API server error (HTTP %d): try again later", status) + } + if msg != "" { + return fmt.Errorf("Sim API error (HTTP %d): %s", status, msg) + } + return fmt.Errorf("Sim API error (HTTP %d)", status) + } +} diff --git a/cmd/sim/evm/evm.go b/cmd/sim/evm/evm.go new file mode 100644 index 0000000..5af6085 --- /dev/null +++ b/cmd/sim/evm/evm.go @@ -0,0 +1,20 @@ +package evm + +import ( + "github.com/spf13/cobra" +) + +// NewEvmCmd returns the `sim evm` parent command. +func NewEvmCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "evm", + Short: "Query EVM chain data (balances, activity, transactions, etc.)", + Long: "Access real-time EVM blockchain data including token balances, activity feeds,\n" + + "transaction history, NFT collectibles, token metadata, token holders,\n" + + "and DeFi positions.", + } + + // Subcommands will be added here as they are implemented. + + return cmd +} diff --git a/cmd/sim/sim.go b/cmd/sim/sim.go new file mode 100644 index 0000000..94536c2 --- /dev/null +++ b/cmd/sim/sim.go @@ -0,0 +1,94 @@ +package sim + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/duneanalytics/cli/authconfig" + "github.com/duneanalytics/cli/cmd/sim/evm" + "github.com/duneanalytics/cli/cmd/sim/svm" + "github.com/duneanalytics/cli/cmdutil" +) + +var simAPIKeyFlag string + +// NewSimCmd returns the `sim` parent command. +func NewSimCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sim", + Short: "Query real-time blockchain data via the Sim API", + Long: "Access real-time blockchain data including balances, activity, transactions,\n" + + "collectibles, token info, token holders, and DeFi positions across EVM and SVM chains.\n\n" + + "Authenticate with a Sim API key via --sim-api-key, the DUNE_SIM_API_KEY environment\n" + + "variable, or by running `dune sim auth`.", + Annotations: map[string]string{"skipAuth": "true"}, + PersistentPreRunE: simPreRun, + } + + cmd.PersistentFlags().StringVar( + &simAPIKeyFlag, "sim-api-key", "", + "Sim API key (overrides DUNE_SIM_API_KEY env var)", + ) + + cmd.AddCommand(evm.NewEvmCmd()) + cmd.AddCommand(svm.NewSvmCmd()) + + return cmd +} + +// simPreRun resolves the Sim API key and stores a SimClient in the command context. +// Commands annotated with "skipSimAuth": "true" bypass this step. +func simPreRun(cmd *cobra.Command, _ []string) error { + // Allow commands like `sim auth` to skip sim client creation. + if cmd.Annotations["skipSimAuth"] == "true" { + return nil + } + + apiKey := resolveSimAPIKey() + if apiKey == "" { + return fmt.Errorf( + "missing Sim API key: set DUNE_SIM_API_KEY, pass --sim-api-key, or run `dune sim auth`", + ) + } + + client := NewSimClient(apiKey) + cmdutil.SetSimClient(cmd, client) + + return nil +} + +// resolveSimAPIKey resolves the Sim API key from (in priority order): +// 1. --sim-api-key flag +// 2. DUNE_SIM_API_KEY environment variable +// 3. sim_api_key from ~/.config/dune/config.yaml +func resolveSimAPIKey() string { + // 1. Flag + if simAPIKeyFlag != "" { + return strings.TrimSpace(simAPIKeyFlag) + } + + // 2. Environment variable + if key := os.Getenv("DUNE_SIM_API_KEY"); key != "" { + return strings.TrimSpace(key) + } + + // 3. Config file + cfg, err := authconfig.Load() + if err != nil || cfg == nil { + return "" + } + return strings.TrimSpace(cfg.SimAPIKey) +} + +// SimClientFromCmd is a convenience helper that extracts and type-asserts the +// SimClient from the command context. +func SimClientFromCmd(cmd *cobra.Command) *SimClient { + v := cmdutil.SimClientFromCmd(cmd) + if v == nil { + return nil + } + return v.(*SimClient) +} diff --git a/cmd/sim/svm/svm.go b/cmd/sim/svm/svm.go new file mode 100644 index 0000000..adc7078 --- /dev/null +++ b/cmd/sim/svm/svm.go @@ -0,0 +1,19 @@ +package svm + +import ( + "github.com/spf13/cobra" +) + +// NewSvmCmd returns the `sim svm` parent command. +func NewSvmCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "svm", + Short: "Query SVM chain data (balances, transactions)", + Long: "Access real-time SVM blockchain data including token balances and\n" + + "transaction history for Solana and Eclipse chains.", + } + + // Subcommands will be added here as they are implemented. + + return cmd +} diff --git a/cmdutil/client.go b/cmdutil/client.go index ad2adb8..b611856 100644 --- a/cmdutil/client.go +++ b/cmdutil/client.go @@ -10,6 +10,7 @@ import ( ) type clientKey struct{} +type simClientKey struct{} type trackerKey struct{} type startTimeKey struct{} @@ -27,6 +28,22 @@ func ClientFromCmd(cmd *cobra.Command) dune.DuneClient { return cmd.Context().Value(clientKey{}).(dune.DuneClient) } +// SetSimClient stores a Sim API client in the command's context. +// The value is stored as any to avoid a circular import with cmd/sim. +func SetSimClient(cmd *cobra.Command, client any) { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + cmd.SetContext(context.WithValue(ctx, simClientKey{}, client)) +} + +// SimClientFromCmd extracts the Sim API client stored in the command's context. +// Callers should type-assert the result to *sim.SimClient. +func SimClientFromCmd(cmd *cobra.Command) any { + return cmd.Context().Value(simClientKey{}) +} + // SetTracker stores a Tracker in the command's context. func SetTracker(cmd *cobra.Command, t *tracking.Tracker) { ctx := cmd.Context() From 184124be15aeee293cca382c27ab820b4a631c1d Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:49:37 +0100 Subject: [PATCH 2/2] update start time --- cmd/sim/auth.go | 63 ++++++++++++++++++++++ cmd/sim/auth_test.go | 124 +++++++++++++++++++++++++++++++++++++++++++ cmd/sim/sim.go | 8 +++ 3 files changed, 195 insertions(+) create mode 100644 cmd/sim/auth.go create mode 100644 cmd/sim/auth_test.go diff --git a/cmd/sim/auth.go b/cmd/sim/auth.go new file mode 100644 index 0000000..4ae6123 --- /dev/null +++ b/cmd/sim/auth.go @@ -0,0 +1,63 @@ +package sim + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/duneanalytics/cli/authconfig" +) + +// NewAuthCmd returns the `sim auth` command. +func NewAuthCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Authenticate with the Sim API", + Long: "Save your Sim API key so you don't need to pass --sim-api-key or set DUNE_SIM_API_KEY every time.", + Annotations: map[string]string{"skipSimAuth": "true"}, + RunE: runSimAuth, + } + + cmd.Flags().String("api-key", "", "Sim API key to save") + + return cmd +} + +func runSimAuth(cmd *cobra.Command, _ []string) error { + key, _ := cmd.Flags().GetString("api-key") + + if key == "" { + key = os.Getenv("DUNE_SIM_API_KEY") + } + + if key == "" { + fmt.Fprint(cmd.ErrOrStderr(), "Enter your Sim API key: ") + scanner := bufio.NewScanner(cmd.InOrStdin()) + if scanner.Scan() { + key = strings.TrimSpace(scanner.Text()) + } + } + + if key == "" { + return fmt.Errorf("no API key provided") + } + + cfg, err := authconfig.Load() + if err != nil { + return fmt.Errorf("loading existing config: %w", err) + } + if cfg == nil { + cfg = &authconfig.Config{} + } + cfg.SimAPIKey = key + if err := authconfig.Save(cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + p, _ := authconfig.Path() + fmt.Fprintf(cmd.OutOrStdout(), "Sim API key saved to %s\n", p) + return nil +} diff --git a/cmd/sim/auth_test.go b/cmd/sim/auth_test.go new file mode 100644 index 0000000..ad950a5 --- /dev/null +++ b/cmd/sim/auth_test.go @@ -0,0 +1,124 @@ +package sim_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/duneanalytics/cli/authconfig" + "github.com/duneanalytics/cli/cmd/sim" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func setupAuthTest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + authconfig.SetDirFunc(func() (string, error) { return dir, nil }) + t.Cleanup(authconfig.ResetDirFunc) + // Clear env var so it doesn't interfere with tests. + t.Setenv("DUNE_SIM_API_KEY", "") + return dir +} + +func newSimAuthRoot() *cobra.Command { + root := &cobra.Command{Use: "dune"} + root.SetContext(context.Background()) + + simCmd := sim.NewSimCmd() + root.AddCommand(simCmd) + + return root +} + +func TestSimAuth_WithFlag(t *testing.T) { + dir := setupAuthTest(t) + + root := newSimAuthRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "auth", "--api-key", "sk_sim_flag_key"}) + require.NoError(t, root.Execute()) + + data, err := os.ReadFile(filepath.Join(dir, "config.yaml")) + require.NoError(t, err) + assert.Contains(t, string(data), "sk_sim_flag_key") + assert.Contains(t, buf.String(), "Sim API key saved to") +} + +func TestSimAuth_WithEnvVar(t *testing.T) { + dir := setupAuthTest(t) + + t.Setenv("DUNE_SIM_API_KEY", "sk_sim_env_key") + + root := newSimAuthRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "auth"}) + require.NoError(t, root.Execute()) + + data, err := os.ReadFile(filepath.Join(dir, "config.yaml")) + require.NoError(t, err) + assert.Contains(t, string(data), "sk_sim_env_key") +} + +func TestSimAuth_WithPrompt(t *testing.T) { + dir := setupAuthTest(t) + + root := newSimAuthRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetIn(strings.NewReader("sk_sim_prompt_key\n")) + root.SetArgs([]string{"sim", "auth"}) + require.NoError(t, root.Execute()) + + data, err := os.ReadFile(filepath.Join(dir, "config.yaml")) + require.NoError(t, err) + assert.Contains(t, string(data), "sk_sim_prompt_key") +} + +func TestSimAuth_EmptyInput(t *testing.T) { + setupAuthTest(t) + + root := newSimAuthRoot() + root.SetIn(strings.NewReader("\n")) + root.SetArgs([]string{"sim", "auth"}) + err := root.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no API key provided") +} + +func TestSimAuth_PreservesExistingConfig(t *testing.T) { + dir := setupAuthTest(t) + + // Pre-populate config with existing fields. + existing := &authconfig.Config{ + APIKey: "existing_dune_key", + } + telemetryTrue := true + existing.Telemetry = &telemetryTrue + require.NoError(t, authconfig.Save(existing)) + + root := newSimAuthRoot() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"sim", "auth", "--api-key", "sk_sim_new"}) + require.NoError(t, root.Execute()) + + // Verify all fields are preserved. + data, err := os.ReadFile(filepath.Join(dir, "config.yaml")) + require.NoError(t, err) + + var cfg authconfig.Config + require.NoError(t, yaml.Unmarshal(data, &cfg)) + + assert.Equal(t, "existing_dune_key", cfg.APIKey, "existing api_key should be preserved") + assert.Equal(t, "sk_sim_new", cfg.SimAPIKey, "sim_api_key should be set") + require.NotNil(t, cfg.Telemetry, "telemetry should be preserved") + assert.True(t, *cfg.Telemetry, "telemetry value should be preserved") +} diff --git a/cmd/sim/sim.go b/cmd/sim/sim.go index 94536c2..1da7cd8 100644 --- a/cmd/sim/sim.go +++ b/cmd/sim/sim.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/spf13/cobra" @@ -33,6 +34,7 @@ func NewSimCmd() *cobra.Command { "Sim API key (overrides DUNE_SIM_API_KEY env var)", ) + cmd.AddCommand(NewAuthCmd()) cmd.AddCommand(evm.NewEvmCmd()) cmd.AddCommand(svm.NewSvmCmd()) @@ -42,6 +44,12 @@ func NewSimCmd() *cobra.Command { // simPreRun resolves the Sim API key and stores a SimClient in the command context. // Commands annotated with "skipSimAuth": "true" bypass this step. func simPreRun(cmd *cobra.Command, _ []string) error { + // The sim command's PersistentPreRunE overrides the root command's hook + // (cobra does not chain PersistentPreRunE without EnableTraverseRunHooks). + // Record the start time here so the root's PersistentPostRunE computes a + // correct duration for telemetry. + cmdutil.SetStartTime(cmd, time.Now()) + // Allow commands like `sim auth` to skip sim client creation. if cmd.Annotations["skipSimAuth"] == "true" { return nil