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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,24 @@ 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 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.
Expand Down
43 changes: 43 additions & 0 deletions cmd/credits.go
Original file line number Diff line number Diff line change
@@ -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})
},
}
25 changes: 25 additions & 0 deletions cmd/credits_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 3 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -27,6 +27,7 @@ func init() {
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(metricCmd)
rootCmd.AddCommand(assetCmd)
rootCmd.AddCommand(userCmd)
}

func SetVersion(v string) {
Expand Down
12 changes: 12 additions & 0 deletions cmd/user.go
Original file line number Diff line number Diff line change
@@ -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)
}
64 changes: 64 additions & 0 deletions internal/api/api_usage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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 (a *APIUsageResponse) CreditsPerMonth() int {
var max int
for _, a := range a.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 (a *APIUsageResponse) Summary() CreditsSummary {
per := a.CreditsPerMonth()
left := per - a.CreditsUsed
if left < 0 {
left = 0
}

return CreditsSummary{
CreditsUsed: a.CreditsUsed,
CreditsPerMonth: per,
CreditsLeft: left,
}
}

// 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
}
76 changes: 76 additions & 0 deletions internal/api/api_usage_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions internal/api/testdata/api_usage.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"creditsUsed":6,"apiAddons":[{"value":1500000}]}
20 changes: 19 additions & 1 deletion skills/glassnode-cli/SKILL.md
Original file line number Diff line number Diff line change
@@ -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`)
Expand Down Expand Up @@ -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
Expand Down
Loading