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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Official CLI for [Depot](https://depot.dev) - you can use the CLI to build Docke
- [`depot login`](#depot-login)
- [`depot logout`](#depot-logout)
- [`depot org`](#depot-org)
- [`depot org credits`](#depot-org-credits)
- [`depot org list`](#depot-org-list)
- [`depot org show`](#depot-org-show)
- [`depot org switch`](#depot-org-switch)
Expand Down Expand Up @@ -749,6 +750,51 @@ depot logout

Manage Depot organizations. The `org` command consists of subcommands for each operation.

#### `depot org credits`

Show the organization's remaining posted Stripe invoice credits. This is the same
balance shown on the billing page; unbilled usage and upcoming invoices are not
deducted, and free-trial allowances and included plan minutes are excluded.

Use an organization API token or a read-only organization token through
`DEPOT_TOKEN`, `--token`, or the CLI configuration. A user token from `depot login`
is not supported by this endpoint. The token determines the organization, so
`depot org switch` does not change which balance is read and there is no `--org`
flag. The command never prompts for login.

```shell
# With DEPOT_TOKEN set to an organization token:
depot org credits
depot org credits --output json
```

Default output uses the currency's decimal places without rounding the balance:

```text
Remaining credits: USD 125.00
Observed at: 2026-10-27T12:00:00Z
```

JSON output preserves the API's exact minor-unit amount as a string, including
`"0"` when credit is exhausted:

```json
{
"remainingCreditMinor": "12500",
"currency": "USD",
"observedAt": "2026-10-27T12:00:00Z"
}
```

For USD, `12500` means $125.00. The timestamp records when the provider balance
was read, not when usage was last processed.

Each invocation reads the API once. Reads are limited to one per organization
per minute across all tokens and ten per ten seconds across the API fleet.
Excess polls exit with an error containing the server's retry delay. The command
does not retry automatically or cache balances. Poll hourly for most credit
alerts, and handle failed commands separately from a successful zero balance.

#### `depot org list`

Display an interactive listing of organizations that you can access. The command shows organization IDs and names in a table format.
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ module github.com/depot/cli
go 1.26.8

require (
buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261026154226-a1d46dc5bdf0.1
buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261026154226-a1d46dc5bdf0.1
buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261027120211-654d30620337.1
buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261027120211-654d30620337.1
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.2
charm.land/huh/v2 v2.0.0
Expand Down Expand Up @@ -242,7 +242,7 @@ require (
golang.org/x/crypto v0.57.0
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/text v0.42.0
golang.org/x/time v0.16.0 // indirect
golang.org/x/tools v0.49.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261026154226-a1d46dc5bdf0.1 h1:euMjmAIWUpU2cDOTtQ+7TEExRs8J9Fw340KgRcovbnQ=
buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261026154226-a1d46dc5bdf0.1/go.mod h1:ocyCo/PaEMnHD7rXfviQM0YvUEaOy0k3494BeBWP3Ak=
buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261026154226-a1d46dc5bdf0.1 h1:w7R+EBUQqZQ8u73PwJ8XPWEz1TjW82bvCoxokx6Yk2k=
buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261026154226-a1d46dc5bdf0.1/go.mod h1:MP5BDdzxwFdhf/KEm27tKfz9dKjtC9C1kXdpuSOb09g=
buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261027120211-654d30620337.1 h1:3BKAM0r63Pho+G/pv3ee2QlecyOfR8qjP+J6aap5fgM=
buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261027120211-654d30620337.1/go.mod h1:wQpUwo9QREPMGVrewsVebSjY7+hVeeCM0tzuUbr8lWs=
buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261027120211-654d30620337.1 h1:PBhFKLoMH99qsKwZ3IHvzFqJ2w/EEtIYhjiSNxq86HM=
buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261027120211-654d30620337.1/go.mod h1:MP5BDdzxwFdhf/KEm27tKfz9dKjtC9C1kXdpuSOb09g=
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
Expand Down
4 changes: 4 additions & 0 deletions pkg/api/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ func NewOrganizationsClient() corev1connect.OrganizationServiceClient {
return corev1connect.NewOrganizationServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent())
}

func NewCreditClient() corev1connect.CreditServiceClient {
return corev1connect.NewCreditServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent())
}

func NewSDKProjectsClient() corev1connect.ProjectServiceClient {
return corev1connect.NewProjectServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent())
}
Expand Down
103 changes: 103 additions & 0 deletions pkg/cmd/org/credits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package org

import (
"context"
"errors"
"fmt"
"strconv"
"time"

corev1 "buf.build/gen/go/depot/api/protocolbuffers/go/depot/core/v1"
"connectrpc.com/connect"
"github.com/depot/cli/pkg/api"
"github.com/depot/cli/pkg/helpers"
"github.com/spf13/cobra"
"golang.org/x/text/currency"
"google.golang.org/protobuf/encoding/protojson"
)

func NewCmdCredits() *cobra.Command {
var token, output string
cmd := &cobra.Command{
Use: "credits",
Short: "Show remaining organization invoice credits",
Long: `Read the organization's posted Stripe invoice-credit balance.
Unbilled usage is not deducted; free-trial and included plan allowances are excluded.

Requires an organization API token or a read-only organization token, supplied
with --token, DEPOT_TOKEN, or the CLI configuration. The token determines the
organization; the current organization selected by org switch is not used.
This command does not prompt for login.

Reads are limited to once per organization per minute and share an API-wide
polling limit. Poll hourly for most monitoring needs. Errors are returned
without retrying or reporting a zero balance.`,
Example: ` # Show remaining credits using an organization token in DEPOT_TOKEN
depot org credits

# Read the exact API response for a monitoring script
depot org credits --output json`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if output != "" && output != "json" {
return fmt.Errorf("--output must be json")
}
resolvedToken := helpers.ResolveStaticOrgAuth(token)
if resolvedToken == "" {
return fmt.Errorf("missing organization API token: set DEPOT_TOKEN or pass --token (read-only organization tokens are supported)")
}

ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()
resp, err := api.NewCreditClient().GetCreditBalance(ctx,
api.WithAuthentication(connect.NewRequest(&corev1.GetCreditBalanceRequest{}), resolvedToken))
if err != nil {
var rpcErr *connect.Error
if errors.As(err, &rpcErr) && rpcErr.Code() == connect.CodeResourceExhausted {
if seconds, parseErr := strconv.Atoi(rpcErr.Meta().Get("Retry-After")); parseErr == nil && seconds > 0 {
return fmt.Errorf("credit balance polling limit exceeded; retry after %d seconds: %w", seconds, err)
}
}
return fmt.Errorf("failed to read credit balance: %w", err)
}

balance := resp.Msg
if balance.RemainingCreditMinor == nil || balance.GetRemainingCreditMinor() < 0 {
return fmt.Errorf("invalid credit balance response: missing or negative amount")
}
unit, err := currency.ParseISO(balance.Currency)
if err != nil || unit == currency.XXX {
return fmt.Errorf("invalid credit balance response: unknown currency")
}
if balance.ObservedAt == nil || balance.ObservedAt.CheckValid() != nil {
return fmt.Errorf("invalid credit balance response: missing or invalid observation time")
}
if output == "json" {
data, err := (protojson.MarshalOptions{Indent: " "}).Marshal(balance)
if err != nil {
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", data)
return err
}

// Keep the calculation in integers so even large balances remain exact.
minor := balance.GetRemainingCreditMinor()
scale, _ := currency.Standard.Rounding(unit)
divisor := int64(1)
for range scale {
divisor *= 10
}
amount := strconv.FormatInt(minor/divisor, 10)
if scale > 0 {
amount += fmt.Sprintf(".%0*d", scale, minor%divisor)
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "Remaining credits: %s %s\nObserved at: %s\n",
unit, amount, balance.ObservedAt.AsTime().UTC().Format(time.RFC3339Nano))
return err
},
}
cmd.Flags().StringVar(&token, "token", "", "Depot organization API token (read-only tokens supported)")
cmd.Flags().StringVarP(&output, "output", "o", "", "Output format (json)")
return cmd
}
Loading
Loading