From c66127a64363013c2a5f885b4d08fe6cac906dbe Mon Sep 17 00:00:00 2001 From: Ivaylo Ivanov Date: Thu, 9 Apr 2026 13:17:35 +0300 Subject: [PATCH 1/3] feat: 'gn user credits' command --- README.md | 8 +++ cmd/credits.go | 43 ++++++++++++++++ cmd/credits_test.go | 25 +++++++++ cmd/root.go | 5 +- cmd/user.go | 12 +++++ internal/api/api_usage.go | 59 +++++++++++++++++++++ internal/api/api_usage_test.go | 76 ++++++++++++++++++++++++++++ internal/api/testdata/api_usage.json | 1 + 8 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 cmd/credits.go create mode 100644 cmd/credits_test.go create mode 100644 cmd/user.go create mode 100644 internal/api/api_usage.go create mode 100644 internal/api/api_usage_test.go create mode 100644 internal/api/testdata/api_usage.json diff --git a/README.md b/README.md index 935b9bf..3ffb2f9 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,14 @@ gn metric get market/marketcap_usd/bulk -a '*' --since 30d For **bulk metrics**, append `/bulk` to the path (e.g. `market/marketcap_usd/bulk`). To pass multiple assets, repeat the `-a` (or `--asset`) flag for each: `-a BTC -a ETH -a SOL`. Use `-a '*'` to request all assets. +### `gn user credits` + +Show current API usage for your account. + +```bash +gn user credits +``` + ### `gn config set key=value` Set a configuration value. diff --git a/cmd/credits.go b/cmd/credits.go new file mode 100644 index 0000000..eb29dfc --- /dev/null +++ b/cmd/credits.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "fmt" + + "github.com/glassnode/glassnode-cli/internal/api" + "github.com/glassnode/glassnode-cli/internal/output" + "github.com/spf13/cobra" +) + +var creditsCmd = &cobra.Command{ + Use: "credits", + Short: "Show the API credits summary for your account", + RunE: func(cmd *cobra.Command, args []string) error { + apiKeyFlag, _ := cmd.Flags().GetString("api-key") + apiKey, err := api.RequireAPIKey(apiKeyFlag) + if err != nil { + return err + } + + client := api.NewClient(apiKey) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + if dryRun { + u, err := client.BuildURL("/v1/user/api_usage", nil, nil) + if err != nil { + return err + } + redacted, _ := api.RedactAPIKeyFromURL(u) + fmt.Println(redacted) + return nil + } + + resp, err := client.GetAPIUsage(cmd.Context()) + if err != nil { + return err + } + + format, _ := cmd.Flags().GetString("output") + tsFmt, _ := cmd.Flags().GetString("timestamp-format") + return output.Print(output.Options{Format: format, Data: resp.Summary(), TimestampFormat: tsFmt}) + }, +} diff --git a/cmd/credits_test.go b/cmd/credits_test.go new file mode 100644 index 0000000..462b0b4 --- /dev/null +++ b/cmd/credits_test.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestCredits_DryRun_RedactsKey(t *testing.T) { + stdout, stderr, err := runCLI(t, nil, + "user", "credits", "--api-key", "secret-key", "--dry-run", + ) + if err != nil { + t.Fatalf("run CLI: %v\nstderr: %s", err, stderr) + } + + if strings.Contains(stdout, "secret-key") { + t.Errorf("stdout must not contain API key: %s", stdout) + } + if !strings.Contains(stdout, "/v1/user/api_usage") { + t.Errorf("stdout should contain path: %s", stdout) + } + if !strings.Contains(stdout, "api_key=***") && !strings.Contains(stdout, "api_key=%2A%2A%2A") { + t.Errorf("stdout should contain redacted api_key: %s", stdout) + } +} diff --git a/cmd/root.go b/cmd/root.go index a82e43b..f931b66 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,8 +7,8 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "gn", - Short: "Glassnode API command-line interface", + Use: "gn", + Short: "Glassnode API command-line interface", SilenceUsage: true, // don't print help when a subcommand returns an error (e.g. API errors) PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // No setup required; API key is resolved via flag/env/config in each command. @@ -27,6 +27,7 @@ func init() { rootCmd.AddCommand(configCmd) rootCmd.AddCommand(metricCmd) rootCmd.AddCommand(assetCmd) + rootCmd.AddCommand(userCmd) } func SetVersion(v string) { diff --git a/cmd/user.go b/cmd/user.go new file mode 100644 index 0000000..e84ab82 --- /dev/null +++ b/cmd/user.go @@ -0,0 +1,12 @@ +package cmd + +import "github.com/spf13/cobra" + +var userCmd = &cobra.Command{ + Use: "user", + Short: "User endpoints", +} + +func init() { + userCmd.AddCommand(creditsCmd) +} diff --git a/internal/api/api_usage.go b/internal/api/api_usage.go new file mode 100644 index 0000000..d01e736 --- /dev/null +++ b/internal/api/api_usage.go @@ -0,0 +1,59 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" +) + +type APIAddon struct { + Value int `json:"value"` +} + +type APIUsageResponse struct { + CreditsUsed int `json:"creditsUsed"` + APIAddons []APIAddon `json:"apiAddons"` +} + +// CreditsPerMonth returns the largest addon credit value, or 0 when there are no addons +func (r *APIUsageResponse) CreditsPerMonth() int { + var max int + for _, a := range r.APIAddons { + if a.Value > max { + max = a.Value + } + } + + return max +} + +// CreditsSummary is the CLI response to the end user +type CreditsSummary struct { + CreditsLeft int `json:"creditsLeft"` + CreditsPerMonth int `json:"creditsPerMonth"` + CreditsUsed int `json:"creditsUsed"` +} + +func (r *APIUsageResponse) Summary() CreditsSummary { + per := r.CreditsPerMonth() + return CreditsSummary{ + CreditsUsed: r.CreditsUsed, + CreditsPerMonth: per, + CreditsLeft: per - r.CreditsUsed, + } +} + +// GetAPIUsage fetches the current API usage for the authenticated user +func (c *Client) GetAPIUsage(ctx context.Context) (*APIUsageResponse, error) { + body, err := c.Do(ctx, "GET", "/v1/user/api_usage", nil) + if err != nil { + return nil, fmt.Errorf("fetching API usage: %w", err) + } + + var out APIUsageResponse + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("decoding API usage response: %w", err) + } + + return &out, nil +} diff --git a/internal/api/api_usage_test.go b/internal/api/api_usage_test.go new file mode 100644 index 0000000..056885e --- /dev/null +++ b/internal/api/api_usage_test.go @@ -0,0 +1,76 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestGetAPIUsage(t *testing.T) { + fixture, err := os.ReadFile("testdata/api_usage.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + var gotPath, gotAPIKey string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAPIKey = r.URL.Query().Get("api_key") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fixture) + })) + defer server.Close() + + client := NewClient("my-key") + client.baseURL = server.URL + client.httpClient = server.Client() + + out, err := client.GetAPIUsage(context.Background()) + if err != nil { + t.Fatalf("GetAPIUsage: %v", err) + } + + if gotPath != "/v1/user/api_usage" { + t.Errorf("path = %q, want /v1/user/api_usage", gotPath) + } + if gotAPIKey != "my-key" { + t.Errorf("api_key = %q, want my-key", gotAPIKey) + } + if out.CreditsUsed != 6 { + t.Errorf("CreditsUsed = %d, want 6", out.CreditsUsed) + } + if len(out.APIAddons) != 1 { + t.Errorf("len(APIAddons) = %d, want 1", len(out.APIAddons)) + } + if out.CreditsPerMonth() != 1500000 { + t.Errorf("CreditsPerMonth() = %d, want 1500000 (max addon value)", out.CreditsPerMonth()) + } + sum := out.Summary() + if sum.CreditsUsed != 6 || sum.CreditsPerMonth != 1500000 || sum.CreditsLeft != 1500000-6 { + t.Errorf("Summary() = %+v, want creditsUsed=6 creditsPerMonth=1500000 creditsLeft=%d", sum, 1500000-6) + } +} + +func TestGetAPIUsage_InvalidJSONReturnsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not json")) + })) + defer server.Close() + + client := NewClient("key") + client.baseURL = server.URL + client.httpClient = server.Client() + + _, err := client.GetAPIUsage(context.Background()) + if err == nil { + t.Fatal("expected error for invalid JSON") + } + + if !strings.Contains(err.Error(), "decoding API usage response") { + t.Errorf("error = %v, want wrapping decode message", err) + } +} diff --git a/internal/api/testdata/api_usage.json b/internal/api/testdata/api_usage.json new file mode 100644 index 0000000..5af70e9 --- /dev/null +++ b/internal/api/testdata/api_usage.json @@ -0,0 +1 @@ +{"creditsUsed":6,"apiAddons":[{"value":1500000}]} From 671943a12ff866b7e24418bdfad8a8afc0c1c1ec Mon Sep 17 00:00:00 2001 From: Ivaylo Ivanov Date: Thu, 9 Apr 2026 15:27:17 +0300 Subject: [PATCH 2/3] fix: dont allow negative number for credits left --- internal/api/api_usage.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/api/api_usage.go b/internal/api/api_usage.go index d01e736..a13cca7 100644 --- a/internal/api/api_usage.go +++ b/internal/api/api_usage.go @@ -16,9 +16,9 @@ type APIUsageResponse struct { } // CreditsPerMonth returns the largest addon credit value, or 0 when there are no addons -func (r *APIUsageResponse) CreditsPerMonth() int { +func (a *APIUsageResponse) CreditsPerMonth() int { var max int - for _, a := range r.APIAddons { + for _, a := range a.APIAddons { if a.Value > max { max = a.Value } @@ -34,12 +34,17 @@ type CreditsSummary struct { CreditsUsed int `json:"creditsUsed"` } -func (r *APIUsageResponse) Summary() CreditsSummary { - per := r.CreditsPerMonth() +func (a *APIUsageResponse) Summary() CreditsSummary { + per := a.CreditsPerMonth() + left := per - a.CreditsUsed + if left < 0 { + left = 0 + } + return CreditsSummary{ - CreditsUsed: r.CreditsUsed, + CreditsUsed: a.CreditsUsed, CreditsPerMonth: per, - CreditsLeft: per - r.CreditsUsed, + CreditsLeft: left, } } From 66281ffd22989fa006c019d71e83d8eebca629cc Mon Sep 17 00:00:00 2001 From: Ivaylo Ivanov Date: Thu, 9 Apr 2026 15:41:31 +0300 Subject: [PATCH 3/3] fix: docs --- README.md | 12 +++++++++++- skills/glassnode-cli/SKILL.md | 20 +++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ffb2f9..6b93e06 100644 --- a/README.md +++ b/README.md @@ -166,12 +166,22 @@ For **bulk metrics**, append `/bulk` to the path (e.g. `market/marketcap_usd/bul ### `gn user credits` -Show current API usage for your account. +Show the current API usage for your account. ```bash gn user credits ``` +Example output: + +```json +{ + "creditsLeft": integer, + "creditsPerMonth": integer, + "creditsUsed": integer +} +``` + ### `gn config set key=value` Set a configuration value. diff --git a/skills/glassnode-cli/SKILL.md b/skills/glassnode-cli/SKILL.md index d3a9716..b57df71 100644 --- a/skills/glassnode-cli/SKILL.md +++ b/skills/glassnode-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: glassnode-cli -description: "Use the Glassnode CLI (gn) to list assets and metrics, fetch on-chain and market data from the Glassnode API, and manage config. Use when the user asks about Glassnode, on-chain data, crypto metrics, gn commands, or needs to call the Glassnode API from the terminal." +description: "Use the Glassnode CLI (gn) to list assets and metrics, fetch on-chain and market data from the Glassnode API, check API credit usage, and manage config. Use when the user asks about Glassnode, on-chain data, crypto metrics, gn commands, or needs to call the Glassnode API from the terminal." --- # Glassnode CLI (`gn`) @@ -66,6 +66,24 @@ gn metric get market/marketcap_usd/bulk -a BTC -a ETH -a SOL -s 1d gn metric get market/marketcap_usd/bulk -a '*' --interval 24h --since 30d ``` +### API credit usage (`gn user credits`) + +Shows the current API usage for the account. + +```bash +gn user credits +``` + +Example JSON output: + +```json +{ + "creditsLeft": integer, + "creditsPerMonth": integer, + "creditsUsed": integer +} +``` + ### Config set / get ```bash gn config set api-key=your-key