diff --git a/CHANGELOG.md b/CHANGELOG.md
index caa30f3..16d2970 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,22 @@
stderr is not a terminal, for builds that are not releases, and with
`STEADYBIT_NO_UPDATE_CHECK` set. The check asks GitHub where its latest release is and
waits for the answer at most a second, once a day.
+- `experiment badge -k ADM-1` prints the Markdown that embeds the experiment's status
+ badge in a README, `--format html` the HTML and `--format url` the image URL;
+ `--tag` prints the badge of a tag instead, which invites to create the experiment
+ while there is none. The URLs carry the tenant key, never the access token. The
+ tenant key is read from the license, which needs an admin access token; `--tenant`
+ gives it otherwise, and with an admin access token must be the token's own tenant.
+ The badge is fetched once without the token, so a wrong tenant key fails the command
+ rather than showing as a broken image. `-t json|yaml` prints every format at once and
+ does not combine with `--format`.
+- `target stats` counts the targets of each type in the tenant, optionally only those
+ matching a target query (`-q`), as a table or with `-t json|yaml` as the platform
+ sends it.
+- `license show` prints the tenant's license, when it expires, and how much of each
+ limit is used; `-t json|yaml` prints the platform's summary. `license report`
+ downloads the license usage report, a zip archive, under the platform's name without
+ overwriting a file, or to `-o`. Both need an admin access token.
## v6.0.1
diff --git a/README.md b/README.md
index 34bc418..833c38e 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,13 @@ steadybit execution artifact list -i 1234
steadybit execution artifact download -i 1234 -d ./artifacts
```
+Show the state of an experiment's latest run in a README with a status badge. The badge
+URL carries the tenant key, never the access token:
+
+```bash
+steadybit experiment badge -k ADM-1 # Markdown; --format html or url
+```
+
### Experiment schedules
```bash
@@ -234,6 +241,7 @@ steadybit access-token create --name ci --type TEAM --team ADM --expires-at 2026
steadybit user invite --email jane@example.com --team ADM
steadybit killswitch status
steadybit audit-log --from 2026-09-01 -t json
+steadybit license show
steadybit report experiments-executed --group-by STATE --rollup MONTHLY
```
@@ -245,6 +253,7 @@ Commands that cannot be undone, such as `killswitch activate`, `access-token del
```bash
steadybit target query -e Global --target-type com.steadybit.extension_container.container --attribute k8s.namespace
steadybit target attribute values -e Global --target-type com.steadybit.extension_container.container -k k8s.namespace
+steadybit target stats -q 'k8s.namespace="shop"'
steadybit action list --kind ATTACK
```
diff --git a/internal/badge/badge.go b/internal/badge/badge.go
new file mode 100644
index 0000000..20c5bf0
--- /dev/null
+++ b/internal/badge/badge.go
@@ -0,0 +1,172 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+// Package badge implements the `experiment badge` command: a status badge to embed in a
+// README, which is served without an access token.
+package badge
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/steadybit/cli/v6/internal/experiment"
+ "github.com/steadybit/cli/v6/internal/output"
+ "github.com/steadybit/cli/v6/internal/platform"
+ "github.com/steadybit/cli/v6/internal/resource"
+)
+
+type Options struct {
+ Key, Tag, CreateCaption string
+ Tenant string
+ Scale int
+ // "markdown", "html" or "url".
+ Format string
+ Type string
+}
+
+type badge struct {
+ Image string `json:"image"`
+ Link string `json:"link"`
+ Markdown string `json:"markdown"`
+ HTML string `json:"html"`
+}
+
+// Print writes the snippet that embeds the badge. The badge URLs carry the tenant key
+// and never the access token: a README is read by anyone, and the platform serves
+// badges to anyone who knows the tenant key.
+func Print(ctx context.Context, c *platform.Client, o Options) error {
+ if (o.Key == "") == (o.Tag == "") {
+ return errors.New("Either --key or --tag must be specified.")
+ }
+ if o.CreateCaption != "" && o.Tag == "" {
+ return errors.New("--create-caption only applies to a badge for --tag.")
+ }
+ if o.Scale < 0 {
+ return errors.New("--scale cannot be negative.")
+ }
+ // Checked before any request: the badge takes up to three.
+ if _, err := output.ResolveDatatype(o.Type, ""); err != nil {
+ return err
+ }
+ format := o.Format
+ if format != "" && resource.Machine(o.Type) {
+ return errors.New("--format cannot be combined with -t or --jq, which print every format.")
+ }
+ if format == "" {
+ format = "markdown"
+ }
+ if format != "markdown" && format != "html" && format != "url" {
+ return fmt.Errorf("Unsupported badge format '%s'. Use \"markdown\", \"html\" or \"url\".", format)
+ }
+ tenant, err := tenantKey(ctx, c, o.Tenant)
+ if err != nil {
+ return err
+ }
+
+ var imagePath, linkPath, alt string
+ image := url.Values{"tenantKey": {tenant}}
+ if o.Scale > 0 {
+ image.Set("scale", fmt.Sprint(o.Scale))
+ }
+ if o.Key != "" {
+ // The badge of a key that does not exist is an image saying "not found", with 200.
+ doc, err := experiment.Fetch(ctx, c, o.Key)
+ if err != nil {
+ return err
+ }
+ team, _ := doc.Get("team")
+ imagePath = "/api/experiments/" + url.PathEscape(o.Key) + "/badge.svg?" + query(image)
+ linkPath = "/experiments/edit/" + url.PathEscape(o.Key) + "?" + query(url.Values{"tenant": {tenant}, "team": {team}})
+ alt = o.Key
+ } else {
+ image.Set("tag", o.Tag)
+ if o.CreateCaption != "" {
+ image.Set("createCaption", o.CreateCaption)
+ }
+ imagePath = "/api/badges/linked-badge.svg?" + query(image)
+ linkPath = "/api/badges/link?" + query(url.Values{"tenantKey": {tenant}, "tag": {o.Tag}})
+ alt = o.Tag
+ }
+ if err := check(ctx, c, imagePath, tenant); err != nil {
+ return err
+ }
+
+ b := badge{Image: c.BaseURL + imagePath, Link: c.BaseURL + linkPath}
+ b.Markdown = fmt.Sprintf("[](%s)", markdownText(alt), b.Image, b.Link)
+ b.HTML = fmt.Sprintf(`
`, html.EscapeString(b.Link), html.EscapeString(alt), html.EscapeString(b.Image))
+ if resource.Machine(o.Type) {
+ raw, _ := json.Marshal(b)
+ return resource.PrintJSONValue(raw, o.Type)
+ }
+ switch format {
+ case "html":
+ fmt.Println(b.HTML)
+ case "url":
+ fmt.Println(b.Image)
+ default:
+ fmt.Println(b.Markdown)
+ }
+ return nil
+}
+
+// query encodes spaces as %20: a badge caption is shown as written, and not every
+// Markdown renderer or server reads + as a space.
+func query(v url.Values) string { return strings.ReplaceAll(v.Encode(), "+", "%20") }
+
+func markdownText(s string) string {
+ return strings.NewReplacer(`\`, `\\`, "[", `\[`, "]", `\]`).Replace(s)
+}
+
+// tenantKey is the one given, or the one the license names. The access token does not
+// say which tenant it belongs to, and the license is the only other place that does.
+// A given key is still compared with the license when it can be read: the badge of
+// another tenant's experiment is an image saying "not found", with 200, which the check
+// of the badge cannot tell from a real one.
+func tenantKey(ctx context.Context, c *platform.Client, given string) (string, error) {
+ var summary struct {
+ TenantKey string `json:"tenantKey"`
+ }
+ resp, err := c.GetLicenseSummary(ctx)
+ _, err = platform.Decode(resp, err, &summary)
+ if given != "" {
+ // Without an admin token the license cannot be read, and the given key is taken as it is.
+ if err == nil && summary.TenantKey != "" && summary.TenantKey != given {
+ return "", fmt.Errorf("The access token belongs to tenant %s, not %s: the badge would show \"not found\". Leave out --tenant, or use an access token of tenant %s.",
+ summary.TenantKey, given, given)
+ }
+ return given, nil
+ }
+ if platform.IsStatus(err, http.StatusForbidden) {
+ return "", errors.New("Finding the tenant key needs an admin access token. Pass it with --tenant: it is the tenant= of a platform URL.")
+ }
+ if err != nil {
+ return "", platform.Failed(err, "Failed to find the tenant key")
+ }
+ if summary.TenantKey == "" {
+ return "", errors.New("The platform did not name the tenant. Pass its key with --tenant: it is the tenant= of a platform URL.")
+ }
+ return summary.TenantKey, nil
+}
+
+// check fetches the badge as a README would show it, without the token, so that a
+// wrong tenant key fails here and not as a broken image.
+func check(ctx context.Context, c *platform.Client, path, tenant string) error {
+ _, resp, err := platform.Read(c.GetAnonymously(ctx, path))
+ var apiErr *platform.APIError
+ if errors.As(err, &apiErr) && apiErr.Status == http.StatusBadRequest && strings.HasSuffix(apiErr.ProblemType(), "/missing-tenant-exception") {
+ return fmt.Errorf("Tenant %s not found.", tenant)
+ }
+ if err != nil {
+ return platform.Failed(err, "Failed to get the badge")
+ }
+ if kind := resp.Header.Get("Content-Type"); !strings.HasPrefix(kind, "image/svg+xml") {
+ return fmt.Errorf("The platform sent %s instead of a badge image.", kind)
+ }
+ return nil
+}
diff --git a/internal/badge/badge_test.go b/internal/badge/badge_test.go
new file mode 100644
index 0000000..55c9875
--- /dev/null
+++ b/internal/badge/badge_test.go
@@ -0,0 +1,137 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package badge_test
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/steadybit/cli/v6/internal/badge"
+ "github.com/steadybit/cli/v6/internal/output"
+ "github.com/steadybit/cli/v6/internal/platformtest"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var ctx = context.Background()
+
+var svg = platformtest.Reply{Body: "", Headers: map[string]string{"Content-Type": "image/svg+xml;charset=UTF-8"}}
+
+func platformWithExperiment(t *testing.T) *platformtest.Platform {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{JSON: map[string]any{"tenantKey": "demo"}})
+ p.Reply("GET /api/experiments/ADM-1", platformtest.Reply{JSON: map[string]any{"key": "ADM-1", "name": "Shop", "team": "ADM"}})
+ p.Reply("GET /api/experiments/ADM-1/badge.svg", svg)
+ return p
+}
+
+func TestPrintsTheBadgeOfAnExperimentAsMarkdown(t *testing.T) {
+ p := platformWithExperiment(t)
+
+ out, err := platformtest.Stdout(t, func() error { return badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1"}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "[]("+p.URL+"/experiments/edit/ADM-1?team=ADM&tenant=demo)\n", out)
+ checked := p.Requests("GET /api/experiments/ADM-1/badge.svg")[0]
+ assert.Equal(t, []string{"demo"}, checked.Query["tenantKey"])
+ // Fetched as a README does: with the token, the platform ignores a wrong tenant key.
+ assert.Empty(t, checked.Header.Get("Authorization"))
+ assert.True(t, strings.HasPrefix(checked.Header.Get("User-Agent"), "steadybit@"))
+ assert.NotEmpty(t, p.Requests("GET /api/experiments/ADM-1")[0].Header.Get("Authorization"))
+}
+
+func TestPrintsTheBadgeAsHTMLOrURL(t *testing.T) {
+ p := platformWithExperiment(t)
+
+ html, err := platformtest.Stdout(t, func() error {
+ return badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Tenant: "demo", Scale: 2, Format: "html"})
+ })
+ require.NoError(t, err)
+ url, err := platformtest.Stdout(t, func() error { return badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Format: "url"}) })
+ require.NoError(t, err)
+
+ assert.Equal(t, `
`+"\n", html)
+ assert.Equal(t, p.URL+"/api/experiments/ADM-1/badge.svg?tenantKey=demo\n", url)
+}
+
+func TestRefusesATenantOtherThanTheTokens(t *testing.T) {
+ p := platformWithExperiment(t)
+
+ err := badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Tenant: "shop"})
+
+ // The badge of another tenant is a 200 image saying "not found": only the license tells.
+ assert.EqualError(t, err, `The access token belongs to tenant demo, not shop: the badge would show "not found". Leave out --tenant, or use an access token of tenant shop.`)
+ assert.Empty(t, p.Requests("GET /api/experiments/ADM-1/badge.svg"))
+}
+
+func TestTakesAGivenTenantWhenTheLicenseCannotBeRead(t *testing.T) {
+ p := platformWithExperiment(t)
+ p.Reply("GET /api/license", platformtest.Reply{Status: http.StatusForbidden})
+
+ out, err := platformtest.Stdout(t, func() error {
+ return badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Tenant: "shop", Format: "url"})
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, p.URL+"/api/experiments/ADM-1/badge.svg?tenantKey=shop\n", out)
+}
+
+func TestPrintsTheBadgeOfATag(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{JSON: map[string]any{"tenantKey": "demo"}})
+ p.Reply("GET /api/badges/linked-badge.svg", svg)
+
+ out, err := platformtest.Stdout(t, func() error {
+ return badge.Print(ctx, p.Client, badge.Options{Tag: "INCIDENT-100", CreateCaption: "Create one (now)", Tenant: "demo"})
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "[]("+
+ p.URL+"/api/badges/link?tag=INCIDENT-100&tenantKey=demo)\n", out)
+ assert.Equal(t, []string{"Create one (now)"}, p.Requests("GET /api/badges/linked-badge.svg")[0].Query["createCaption"])
+}
+
+func TestPrintsTheBadgeAsJSON(t *testing.T) {
+ p := platformWithExperiment(t)
+ output.JQ = ".image"
+ t.Cleanup(func() { output.JQ = "" })
+
+ out, err := platformtest.Stdout(t, func() error { return badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1"}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, p.URL+"/api/experiments/ADM-1/badge.svg?tenantKey=demo\n", out)
+}
+
+func TestReportsAWrongTenantOrExperiment(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Status: http.StatusForbidden})
+ p.Reply("GET /api/experiments/ADM-1", platformtest.Reply{JSON: map[string]any{"key": "ADM-1", "team": "ADM"}})
+ p.Reply("GET /api/experiments/ADM-2", platformtest.Reply{Status: http.StatusNotFound})
+ p.Reply("GET /api/experiments/ADM-1/badge.svg", platformtest.Reply{Status: http.StatusBadRequest,
+ Body: `{"type":"https://steadybit.com/problems/missing-tenant-exception","title":"A tenant must be set","status":400}`})
+
+ assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Tenant: "nosuch"}), "Tenant nosuch not found.")
+ assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-2", Tenant: "demo"}), "Experiment ADM-2 not found.")
+}
+
+func TestFindingTheTenantNeedsAnAdminToken(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Status: http.StatusForbidden})
+
+ assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1"}),
+ "Finding the tenant key needs an admin access token. Pass it with --tenant: it is the tenant= of a platform URL.")
+}
+
+func TestRefusals(t *testing.T) {
+ p := platformtest.New(t)
+
+ assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{}), "Either --key or --tag must be specified.")
+ assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", CreateCaption: "x"}), "--create-caption only applies to a badge for --tag.")
+ assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Format: "svg"}), `Unsupported badge format 'svg'. Use "markdown", "html" or "url".`)
+ assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Type: "xml"}), `unsupported output format 'xml'. Use "json" or "yaml"`)
+ assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Type: "json", Format: "html"}), "--format cannot be combined with -t or --jq, which print every format.")
+ // Refused before any request: the platform has no route to answer.
+}
diff --git a/internal/cli/badge.go b/internal/cli/badge.go
new file mode 100644
index 0000000..d07d468
--- /dev/null
+++ b/internal/cli/badge.go
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package cli
+
+import (
+ "context"
+
+ "github.com/spf13/cobra"
+ "github.com/steadybit/cli/v6/internal/badge"
+ "github.com/steadybit/cli/v6/internal/platform"
+)
+
+func newExperimentBadge() *cobra.Command {
+ var o badge.Options
+ cmd := &cobra.Command{
+ Use: "badge",
+ Short: "Print a status badge of an experiment to paste into a README: its latest run's state, linking to the platform.",
+ Long: `Print a status badge of an experiment to paste into a README: its latest run's state, linking to the platform.
+
+With --tag, such as an incident id, the badge shows the latest run of the experiment
+having the tag, or, while there is none, invites to create one with the tag.
+
+The badge URL carries no access token: anyone who knows the tenant key can load it,
+and it shows the experiment key and the state of its latest run. The link opens the
+platform, which asks to log in. Finding the tenant key needs an admin access token;
+with any other, pass it with --tenant. With an admin access token, --tenant must be
+the token's own tenant.
+
+-t prints the image URL, the link and every snippet at once, so it does not combine
+with --format.`,
+ Args: cobra.NoArgs,
+ Example: examples(
+ "steadybit experiment badge -k ADM-1",
+ "steadybit experiment badge -k ADM-1 --format html --scale 2",
+ `steadybit experiment badge --tag INCIDENT-100 --create-caption "Create experiment for incident 100" --tenant demo`,
+ ),
+ RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return badge.Print(ctx, c, o) }),
+ }
+ f := cmd.Flags()
+ f.StringVarP(&o.Key, "key", "k", "", "The experiment key.")
+ f.StringVar(&o.Tag, "tag", "", "Instead of --key: the tag of the experiments the badge is for.")
+ f.StringVar(&o.CreateCaption, "create-caption", "", "With --tag: the caption shown while no experiment has the tag. (default: the platform's, \"Create experiment\")")
+ f.StringVar(&o.Tenant, "tenant", "", "The tenant key, the tenant= of a platform URL. (default: read from the license)")
+ f.IntVar(&o.Scale, "scale", 0, "Scale the badge image by this factor. (default: the platform's, 1)")
+ f.StringVar(&o.Format, "format", "", `Print the badge as "markdown", "html", or only the image "url". Not with -t or --jq. (default: markdown)`)
+ f.StringVarP(&o.Type, "type", "t", "", `Print the image URL, link and snippets as "json" or "yaml" instead. Not with --format.`)
+ cmd.MarkFlagsMutuallyExclusive("key", "tag")
+ return cmd
+}
diff --git a/internal/cli/experiment.go b/internal/cli/experiment.go
index 2ca91f7..4ed02de 100644
--- a/internal/cli/experiment.go
+++ b/internal/cli/experiment.go
@@ -18,7 +18,7 @@ import (
func newExperiment() *cobra.Command {
cmd := &cobra.Command{Use: "experiment", Short: "Check and run experiments."}
- cmd.AddCommand(newExperimentRun(), newExperimentGet(), newExperimentApply(), newExperimentDelete(), newExperimentDump(), newExperimentInit(),
+ cmd.AddCommand(newExperimentRun(), newExperimentGet(), newExperimentApply(), newExperimentDelete(), newExperimentDump(), newExperimentInit(), newExperimentBadge(),
newDiff(gitops.Experiment, "experiment", "experiment.yml"))
return cmd
}
diff --git a/internal/cli/license.go b/internal/cli/license.go
new file mode 100644
index 0000000..56d92a4
--- /dev/null
+++ b/internal/cli/license.go
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package cli
+
+import (
+ "context"
+
+ "github.com/spf13/cobra"
+ "github.com/steadybit/cli/v6/internal/license"
+ "github.com/steadybit/cli/v6/internal/platform"
+)
+
+func newLicense() *cobra.Command {
+ cmd := &cobra.Command{Use: "license", Short: "Show the license of the tenant and what of it is used. Needs an admin access token."}
+
+ var s license.ShowOptions
+ show := &cobra.Command{
+ Use: "show",
+ Short: "Show the license, when it expires, and how much of each limit is used.",
+ Args: cobra.NoArgs,
+ Example: examples("steadybit license show", "steadybit license show --jq '.expires'"),
+ RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return license.Show(ctx, c, s) }),
+ }
+ show.Flags().StringVarP(&s.Type, "type", "t", "", `Print the license summary as "json" or "yaml" instead of text.`)
+
+ var r license.ReportOptions
+ report := &cobra.Command{
+ Use: "report",
+ Short: "Download the license usage report, a zip archive with the usage of each license period.",
+ Args: cobra.NoArgs,
+ Example: examples("steadybit license report", "steadybit license report -o usage.zip"),
+ RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return license.Report(ctx, c, r) }),
+ }
+ report.Flags().StringVarP(&r.Output, "output", "o", "", "Write the report to this file, overwriting it. (default: the name the platform gives it, in the current directory, which is never overwritten)")
+
+ cmd.AddCommand(show, report)
+ return cmd
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 571f4fe..5eea296 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -75,7 +75,7 @@ func newRoot() *cobra.Command {
root.PersistentFlags().StringVar(&output.JQ, "jq", "", "Filter the JSON a command prints with a jq expression; strings are printed raw.")
root.Flags().BoolP("version", "V", false, "output the version number")
root.SetVersionTemplate("{{.Version}}\n")
- root.AddCommand(newAccessToken(), newAction(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newHub(), newIntegration(), newKillswitch(), newProperty(), newReport(), newSchedule(), newService(), newServiceProfile(), newTarget(), newTeam(), newTemplate(), newUser(),
+ root.AddCommand(newAccessToken(), newAction(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newHub(), newIntegration(), newKillswitch(), newLicense(), newProperty(), newReport(), newSchedule(), newService(), newServiceProfile(), newTarget(), newTeam(), newTemplate(), newUser(),
newExport(), newApplyProject(), newDiffProject())
// Shell completion is new with the Go CLI; it gets examples like every other command.
root.InitDefaultCompletionCmd()
diff --git a/internal/cli/target.go b/internal/cli/target.go
index 33270eb..097e353 100644
--- a/internal/cli/target.go
+++ b/internal/cli/target.go
@@ -75,7 +75,21 @@ func newTarget() *cobra.Command {
_ = values.MarkFlagRequired("key")
attribute.AddCommand(keys, values)
- cmd.AddCommand(query, attribute)
+ var s target.StatsOptions
+ stats := &cobra.Command{
+ Use: "stats",
+ Short: "Count the targets of each type in the tenant, optionally only those matching a query.",
+ Args: cobra.NoArgs,
+ Example: examples(
+ "steadybit target stats",
+ `steadybit target stats -q 'k8s.namespace="shop"' -t json`,
+ ),
+ RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return target.Stats(ctx, c, s) }),
+ }
+ stats.Flags().StringVarP(&s.Query, "query", "q", "", "Only count targets matching this target query.")
+ stats.Flags().StringVarP(&s.Type, "type", "t", "", `Print the counts by target type as "json" or "yaml" instead of a table.`)
+
+ cmd.AddCommand(query, attribute, stats)
return cmd
}
diff --git a/internal/license/license.go b/internal/license/license.go
new file mode 100644
index 0000000..1a3e39e
--- /dev/null
+++ b/internal/license/license.go
@@ -0,0 +1,234 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+// Package license implements the `license` commands.
+package license
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "mime"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/steadybit/cli/v6/internal/output"
+ "github.com/steadybit/cli/v6/internal/platform"
+ "github.com/steadybit/cli/v6/internal/resource"
+ "github.com/steadybit/cli/v6/internal/table"
+)
+
+var errNotAdmin = errors.New("The license needs an admin access token.")
+
+type ShowOptions struct {
+ Type string
+}
+
+type summary struct {
+ License *struct {
+ LicenseType string `json:"licenseType"`
+ OrderNumber string `json:"orderNumber"`
+ ValidFrom string `json:"validFrom"`
+ ValidTo string `json:"validTo"`
+ } `json:"license"`
+ Expires *time.Time `json:"expires"`
+ TenantKey string `json:"tenantKey"`
+ Features []feature `json:"features"`
+}
+
+type feature struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Usage *int64 `json:"usage"`
+ SoftLimit *int64 `json:"softLimit"`
+ HardLimit *int64 `json:"hardLimit"`
+}
+
+// Show prints the license of the tenant and how much of each limit is used.
+func Show(ctx context.Context, c *platform.Client, o ShowOptions) error {
+ if _, err := output.ResolveDatatype(o.Type, ""); err != nil {
+ return err
+ }
+ body, _, err := platform.Read(c.GetLicenseSummary(ctx))
+ if platform.IsStatus(err, http.StatusForbidden) {
+ return errNotAdmin
+ }
+ if err != nil {
+ return platform.Failed(err, "Failed to get the license")
+ }
+ if resource.Machine(o.Type) {
+ return resource.PrintJSONValue(body, o.Type)
+ }
+ var s summary
+ if err := json.Unmarshal(body, &s); err != nil {
+ return fmt.Errorf("Failed to read the license: %w", err)
+ }
+ if s.License == nil || s.License.LicenseType == "" || s.License.LicenseType == "NONE" {
+ fmt.Println("The tenant has no license.")
+ return nil
+ }
+ fmt.Println(sentence(s, time.Now()))
+
+ // The platform sends the features in no particular order.
+ sort.Slice(s.Features, func(i, j int) bool { return s.Features[i].Name < s.Features[j].Name })
+ // Features without a limit are only on or off; they make a list, not table rows.
+ var included []string
+ t := table.New(
+ table.Column{Name: "feature", Title: "Limit", Alignment: table.Left},
+ table.Column{Name: "used", Title: "Used", Alignment: table.Right},
+ table.Column{Name: "limit", Title: "Licensed", Alignment: table.Right},
+ )
+ limited := false
+ for _, f := range s.Features {
+ if f.Type == "SIMPLE" {
+ included = append(included, f.Name)
+ continue
+ }
+ limited = true
+ limit, color := limitOf(f)
+ used := ""
+ if f.Usage != nil {
+ used = fmt.Sprint(*f.Usage)
+ }
+ t.AddRow(color, table.Cell("feature", f.Name), table.Cell("used", used), table.Cell("limit", limit))
+ }
+ if limited {
+ t.Print()
+ }
+ if len(included) > 0 {
+ fmt.Printf("Included: %s\n", strings.Join(included, ", "))
+ }
+ return nil
+}
+
+// sentence describes the license from the parts the platform sent: an order number or
+// a tenant key can be missing, and must not leave a gap or a dangling "of tenant".
+func sentence(s summary, now time.Time) string {
+ text := title(s.License.LicenseType) + " license"
+ if s.License.OrderNumber != "" {
+ text += " " + s.License.OrderNumber
+ }
+ if s.TenantKey != "" {
+ text += " of tenant " + s.TenantKey
+ }
+ switch from, to := s.License.ValidFrom, s.License.ValidTo; {
+ case from != "" && to != "":
+ text += ", valid from " + from + " to " + to
+ case from != "":
+ text += ", valid from " + from
+ case to != "":
+ text += ", valid to " + to
+ }
+ return text + expiry(s.Expires, now) + "."
+}
+
+// limitOf is the licensed amount of a feature and whether its usage is highlighted.
+// The feature's type says which limit applies; the platform can send the other field
+// too. A hard limit is highlighted once reached, since nothing more can be added; a
+// soft one only once exceeded.
+func limitOf(f feature) (string, table.Color) {
+ highlight := func(over bool) table.Color {
+ if over {
+ return table.Red
+ }
+ return table.Default
+ }
+ switch f.Type {
+ case "SOFT_LIMIT":
+ if f.SoftLimit != nil {
+ return fmt.Sprintf("%d (soft)", *f.SoftLimit), highlight(f.Usage != nil && *f.Usage > *f.SoftLimit)
+ }
+ case "HARD_LIMIT":
+ if f.HardLimit != nil {
+ return fmt.Sprint(*f.HardLimit), highlight(f.Usage != nil && *f.Usage >= *f.HardLimit)
+ }
+ }
+ return "unlimited", table.Default
+}
+
+func title(licenseType string) string {
+ return strings.ToUpper(licenseType[:1]) + strings.ToLower(licenseType[1:])
+}
+
+// expiry warns of a license that has run out or is about to: a pipeline's runs stop with it.
+func expiry(expires *time.Time, now time.Time) string {
+ if expires == nil {
+ return ""
+ }
+ left := expires.Sub(now)
+ switch {
+ case left <= 0:
+ return ", expired"
+ case left < 30*24*time.Hour:
+ days := int(left.Hours()/24) + 1
+ if days == 1 {
+ return ", expires in 1 day"
+ }
+ return fmt.Sprintf(", expires in %d days", days)
+ }
+ return ""
+}
+
+type ReportOptions struct {
+ Output string
+}
+
+// Report downloads the license usage report, a zip archive of the tenant's usage over
+// each license period, as the platform names it unless an output file is given. Only a
+// file given with -o is overwritten: the platform's name is not the user's choice, and
+// could be that of any file in the current directory.
+func Report(ctx context.Context, c *platform.Client, o ReportOptions) error {
+ // The report covers every license period; building it takes longer than an API response.
+ ctx = platform.WithTimeout(ctx, 5*time.Minute)
+ content, resp, err := platform.Read(c.GetReport(ctx))
+ if platform.IsStatus(err, http.StatusForbidden) {
+ return errNotAdmin
+ }
+ if err != nil {
+ return platform.Failed(err, "Failed to download the license report")
+ }
+ file, flags := o.Output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC
+ if file == "" {
+ file, flags = fileName(resp.Header.Get("Content-Disposition")), os.O_WRONLY|os.O_CREATE|os.O_EXCL
+ }
+ if dir := filepath.Dir(file); dir != "." {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ }
+ f, err := os.OpenFile(file, flags, 0o644)
+ if errors.Is(err, os.ErrExist) {
+ return fmt.Errorf("%s already exists. Pass -o %s to overwrite it, or -o another file.", file, file)
+ }
+ if err != nil {
+ return err
+ }
+ _, err = f.Write(content)
+ if closeErr := f.Close(); err == nil {
+ err = closeErr
+ }
+ if err != nil {
+ return err
+ }
+ fmt.Printf("License report written to %s.\n", file)
+ return nil
+}
+
+// fileName takes the name the platform gives the report, but only as one path segment
+// and not as a hidden file: the header must not decide where on disk the file goes.
+func fileName(disposition string) string {
+ _, params, err := mime.ParseMediaType(disposition)
+ if err != nil {
+ return "license-report.zip"
+ }
+ name := strings.TrimLeft(output.PathSegment(strings.ReplaceAll(params["filename"], `\`, "/")), ".")
+ if name == "" || name == "_" {
+ return "license-report.zip"
+ }
+ return name
+}
diff --git a/internal/license/license_test.go b/internal/license/license_test.go
new file mode 100644
index 0000000..54a14ef
--- /dev/null
+++ b/internal/license/license_test.go
@@ -0,0 +1,230 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package license_test
+
+import (
+ "context"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/steadybit/cli/v6/internal/license"
+ "github.com/steadybit/cli/v6/internal/output"
+ "github.com/steadybit/cli/v6/internal/platformtest"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var ctx = context.Background()
+
+const summary = `{"license":{"id":1,"licenseType":"ENTERPRISE","orderNumber":"1234-2","validFrom":"2026-01-22","validTo":"2099-01-01"},
+ "expires":"2099-01-01T00:00:00Z","tenantKey":"demo","features":[
+ {"name":"TEMPLATES","type":"SIMPLE","usage":0},
+ {"name":"SERVICES","type":"HARD_LIMIT","usage":60,"hardLimit":50},
+ {"name":"AUDIT_LOG","type":"SIMPLE","usage":0},
+ {"name":"ENVIRONMENT_SIZE","type":"SOFT_LIMIT","usage":45,"softLimit":100},
+ {"name":"USER_SIZE","type":"HARD_LIMIT","usage":22}]}`
+
+func TestShowsTheLicenseAndItsLimits(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Body: summary})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, `Enterprise license 1234-2 of tenant demo, valid from 2026-01-22 to 2099-01-01.
+┌──────────────────┬──────┬────────────┐
+│ Limit │ Used │ Licensed │
+├──────────────────┼──────┼────────────┤
+│ ENVIRONMENT_SIZE │ 45 │ 100 (soft) │
+│ SERVICES │ 60 │ 50 │
+│ USER_SIZE │ 22 │ unlimited │
+└──────────────────┴──────┴────────────┘
+Included: AUDIT_LOG, TEMPLATES
+`, out)
+}
+
+func TestSaysWhenTheLicenseHasExpired(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"license":{"licenseType":"TRIAL","orderNumber":"7","validFrom":"2020-01-01","validTo":"2020-02-01"},"expires":"2020-02-01T00:00:00Z","tenantKey":"demo","features":[]}`})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "Trial license 7 of tenant demo, valid from 2020-01-01 to 2020-02-01, expired.\n", out)
+}
+
+func TestWarnsOfALicenseAboutToExpire(t *testing.T) {
+ p := platformtest.New(t)
+ expires := time.Now().Add(10 * 24 * time.Hour).UTC().Format(time.RFC3339)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"license":{"licenseType":"PROFESSIONAL","orderNumber":"8","validFrom":"2020-01-01","validTo":"` + expires[:10] + `"},"expires":"` + expires + `","tenantKey":"demo"}`})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "Professional license 8 of tenant demo, valid from 2020-01-01 to "+expires[:10]+", expires in 10 days.\n", out)
+}
+
+func TestSaysWhenThereIsNoLicense(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"license":null,"tenantKey":"demo"}`})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "The tenant has no license.\n", out)
+}
+
+func TestPrintsTheLicenseAsThePlatformSendsIt(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"tenantKey":"demo","expires":"2099-01-01T00:00:00Z"}`})
+
+ yaml, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{Type: "yaml"}) })
+ require.NoError(t, err)
+ output.JQ = ".expires"
+ t.Cleanup(func() { output.JQ = "" })
+ jq, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+ require.NoError(t, err)
+
+ assert.Equal(t, "tenantKey: demo\nexpires: '2099-01-01T00:00:00Z'\n", yaml)
+ assert.Equal(t, "2099-01-01T00:00:00Z\n", jq)
+}
+
+func TestDownloadsTheReportUnderItsName(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license/report", platformtest.Reply{Body: "PK-zip", Headers: map[string]string{
+ "Content-Disposition": `attachment; filename="../license-usage-reports-demo.zip"`,
+ }})
+ t.Chdir(t.TempDir())
+
+ out, err := platformtest.Stdout(t, func() error { return license.Report(ctx, p.Client, license.ReportOptions{}) })
+ require.NoError(t, err)
+ named, _ := os.ReadFile("license-usage-reports-demo.zip")
+ file := filepath.Join("reports", "usage.zip")
+ _, err = platformtest.Stdout(t, func() error { return license.Report(ctx, p.Client, license.ReportOptions{Output: file}) })
+ require.NoError(t, err)
+ given, _ := os.ReadFile(file)
+
+ assert.Equal(t, "License report written to license-usage-reports-demo.zip.\n", out)
+ assert.Equal(t, "PK-zip", string(named))
+ assert.Equal(t, "PK-zip", string(given))
+}
+
+func TestDownloadsTheReportWithoutAName(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license/report", platformtest.Reply{Body: "PK-zip"})
+ t.Chdir(t.TempDir())
+
+ out, err := platformtest.Stdout(t, func() error { return license.Report(ctx, p.Client, license.ReportOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "License report written to license-report.zip.\n", out)
+ assert.FileExists(t, "license-report.zip")
+}
+
+func TestNeedsAnAdminToken(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Status: http.StatusForbidden})
+ p.Reply("GET /api/license/report", platformtest.Reply{Status: http.StatusForbidden})
+ t.Chdir(t.TempDir())
+
+ assert.EqualError(t, license.Show(ctx, p.Client, license.ShowOptions{}), "The license needs an admin access token.")
+ assert.EqualError(t, license.Report(ctx, p.Client, license.ReportOptions{}), "The license needs an admin access token.")
+ assert.NoFileExists(t, "license-report.zip")
+}
+
+func TestLeavesOutTheMissingPartsOfTheLicense(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"license":{"licenseType":"TRIAL","orderNumber":"","validFrom":"2026-01-01","validTo":"2099-01-01"},"tenantKey":null,"features":[]}`})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "Trial license, valid from 2026-01-01 to 2099-01-01.\n", out)
+}
+
+func TestSaysOneDay(t *testing.T) {
+ p := platformtest.New(t)
+ expires := time.Now().Add(12 * time.Hour).UTC().Format(time.RFC3339)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"license":{"licenseType":"TRIAL","orderNumber":"8","validFrom":"2020-01-01","validTo":"` + expires[:10] + `"},"expires":"` + expires + `","tenantKey":"demo"}`})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "Trial license 8 of tenant demo, valid from 2020-01-01 to "+expires[:10]+", expires in 1 day.\n", out)
+}
+
+func TestTakesTheLimitTheTypeOfTheFeatureNames(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"license":{"licenseType":"ENTERPRISE","orderNumber":"1","validFrom":"2026-01-01","validTo":"2099-01-01"},"tenantKey":"demo","features":[
+ {"name":"HARD","type":"HARD_LIMIT","usage":5,"softLimit":3,"hardLimit":10},
+ {"name":"SOFT","type":"SOFT_LIMIT","usage":5,"softLimit":3,"hardLimit":10},
+ {"name":"UNLIMITED","type":"HARD_LIMIT","usage":5,"softLimit":3}]}`})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, `Enterprise license 1 of tenant demo, valid from 2026-01-01 to 2099-01-01.
+┌───────────┬──────┬───────────┐
+│ Limit │ Used │ Licensed │
+├───────────┼──────┼───────────┤
+│ HARD │ 5 │ 10 │
+│ SOFT │ 5 │ 3 (soft) │
+│ UNLIMITED │ 5 │ unlimited │
+└───────────┴──────┴───────────┘
+`, out)
+}
+
+func TestRejectsAWrongTypeBeforeAnyRequest(t *testing.T) {
+ p := platformtest.New(t)
+
+ assert.EqualError(t, license.Show(ctx, p.Client, license.ShowOptions{Type: "xml"}), `unsupported output format 'xml'. Use "json" or "yaml"`)
+ assert.Empty(t, p.Requests("GET /api/license"))
+}
+
+func TestNeverOverwritesAFileThePlatformNamed(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license/report", platformtest.Reply{Body: "PK-zip", Headers: map[string]string{
+ "Content-Disposition": `attachment; filename="usage.zip"`,
+ }})
+ t.Chdir(t.TempDir())
+ require.NoError(t, os.WriteFile("usage.zip", []byte("mine"), 0o644))
+
+ err := license.Report(ctx, p.Client, license.ReportOptions{})
+ assert.EqualError(t, err, "usage.zip already exists. Pass -o usage.zip to overwrite it, or -o another file.")
+ kept, _ := os.ReadFile("usage.zip")
+ assert.Equal(t, "mine", string(kept))
+
+ // Named with -o, the file is the user's choice.
+ _, err = platformtest.Stdout(t, func() error { return license.Report(ctx, p.Client, license.ReportOptions{Output: "usage.zip"}) })
+ require.NoError(t, err)
+ written, _ := os.ReadFile("usage.zip")
+ assert.Equal(t, "PK-zip", string(written))
+}
+
+func TestNeverWritesAHiddenOrEmptyName(t *testing.T) {
+ for disposition, want := range map[string]string{
+ `attachment; filename=".bashrc"`: "bashrc",
+ `attachment; filename="../.x.zip"`: "x.zip",
+ `attachment; filename=".."`: "license-report.zip",
+ `attachment; filename="."`: "license-report.zip",
+ `attachment; filename="/"`: "license-report.zip",
+ `attachment; filename=""`: "license-report.zip",
+ `attachment; filename="reports/..."`: "license-report.zip",
+ } {
+ t.Run(disposition, func(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license/report", platformtest.Reply{Body: "PK-zip", Headers: map[string]string{"Content-Disposition": disposition}})
+ t.Chdir(t.TempDir())
+
+ out, err := platformtest.Stdout(t, func() error { return license.Report(ctx, p.Client, license.ReportOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "License report written to "+want+".\n", out)
+ assert.FileExists(t, want)
+ })
+ }
+}
diff --git a/internal/license/limit_test.go b/internal/license/limit_test.go
new file mode 100644
index 0000000..e60896f
--- /dev/null
+++ b/internal/license/limit_test.go
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package license
+
+import (
+ "testing"
+
+ "github.com/steadybit/cli/v6/internal/table"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestHighlightsAHardLimitOnceReachedAndASoftOneOnceExceeded(t *testing.T) {
+ n := func(v int64) *int64 { return &v }
+ for _, c := range []struct {
+ name string
+ f feature
+ limit string
+ color table.Color
+ }{
+ {"below a hard limit", feature{Type: "HARD_LIMIT", Usage: n(9), HardLimit: n(10)}, "10", table.Default},
+ {"at a hard limit", feature{Type: "HARD_LIMIT", Usage: n(10), HardLimit: n(10)}, "10", table.Red},
+ {"over a hard limit", feature{Type: "HARD_LIMIT", Usage: n(11), HardLimit: n(10)}, "10", table.Red},
+ {"at a soft limit", feature{Type: "SOFT_LIMIT", Usage: n(10), SoftLimit: n(10)}, "10 (soft)", table.Default},
+ {"over a soft limit", feature{Type: "SOFT_LIMIT", Usage: n(11), SoftLimit: n(10)}, "10 (soft)", table.Red},
+ // The type decides, not which field is sent.
+ {"a soft limit also sending a hard one", feature{Type: "SOFT_LIMIT", Usage: n(5), SoftLimit: n(3), HardLimit: n(10)}, "3 (soft)", table.Red},
+ {"a hard limit also sending a soft one", feature{Type: "HARD_LIMIT", Usage: n(5), SoftLimit: n(3), HardLimit: n(10)}, "10", table.Default},
+ {"a hard limit without an amount", feature{Type: "HARD_LIMIT", Usage: n(5), SoftLimit: n(3)}, "unlimited", table.Default},
+ {"no usage", feature{Type: "HARD_LIMIT", HardLimit: n(0)}, "0", table.Default},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ limit, color := limitOf(c.f)
+ assert.Equal(t, c.limit, limit)
+ assert.Equal(t, c.color, color)
+ })
+ }
+}
diff --git a/internal/platform/client.go b/internal/platform/client.go
index 8c910a9..a51dfb1 100644
--- a/internal/platform/client.go
+++ b/internal/platform/client.go
@@ -89,7 +89,7 @@ type Client struct {
// Get fetches a path the spec has no operation for, such as the Location of a run.
func (c *Client) Get(ctx context.Context, path string) (*http.Response, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
+ req, err := c.newRequest(ctx, http.MethodGet, path)
if err != nil {
return nil, err
}
@@ -99,6 +99,29 @@ func (c *Client) Get(ctx context.Context, path string) (*http.Response, error) {
return c.http.Do(req)
}
+// GetAnonymously fetches a path without the access token, as a README showing a badge
+// does: with the token, the platform takes the tenant from it and ignores a wrong one
+// in the URL.
+func (c *Client) GetAnonymously(ctx context.Context, path string) (*http.Response, error) {
+ req, err := c.newRequest(ctx, http.MethodGet, path)
+ if err != nil {
+ return nil, err
+ }
+ return c.http.Do(req)
+}
+
+// newRequest is a request to a path of the platform, before any authorization.
+func (c *Client) newRequest(ctx context.Context, method, path string) (*http.Request, error) {
+ req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", userAgent())
+ return req, nil
+}
+
+func userAgent() string { return "steadybit@" + CurrentVersion() }
+
var Verbose bool
func New() (*Client, error) {
@@ -124,7 +147,7 @@ func New() (*Client, error) {
authorize := func(_ context.Context, req *http.Request) error {
req.Header.Set("Authorization", "accessToken "+cfg.APIAccessToken)
req.Header.Set("Accept", "application/json, */*")
- req.Header.Set("User-Agent", "steadybit@"+CurrentVersion())
+ req.Header.Set("User-Agent", userAgent())
return nil
}
client, err := api.NewClientWithResponses(cfg.BaseURL, api.WithHTTPClient(httpClient), api.WithRequestEditorFn(authorize))
diff --git a/internal/target/stats.go b/internal/target/stats.go
new file mode 100644
index 0000000..e1652f5
--- /dev/null
+++ b/internal/target/stats.go
@@ -0,0 +1,67 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package target
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "sort"
+
+ "github.com/steadybit/cli/v6/api"
+ "github.com/steadybit/cli/v6/internal/jsyaml"
+ "github.com/steadybit/cli/v6/internal/output"
+ "github.com/steadybit/cli/v6/internal/platform"
+ "github.com/steadybit/cli/v6/internal/resource"
+ "github.com/steadybit/cli/v6/internal/table"
+)
+
+type StatsOptions struct {
+ Query string
+ Type string
+}
+
+// Stats prints how many targets of each type the platform knows. The platform counts
+// over the whole tenant: it takes a query but no environment.
+func Stats(ctx context.Context, c *platform.Client, o StatsOptions) error {
+ if _, err := output.ResolveDatatype(o.Type, ""); err != nil {
+ return err
+ }
+ var resp *http.Response
+ var err error
+ if o.Query == "" {
+ resp, err = c.GetTargetsStats(ctx)
+ } else {
+ resp, err = c.GetTargetsStats1(ctx, api.TargetStatsRequest{Query: &o.Query})
+ }
+ body, _, err := platform.Read(resp, err)
+ if err != nil {
+ return platform.Failed(err, "Failed to get the target statistics")
+ }
+ if resource.Machine(o.Type) {
+ return resource.PrintJSONValue(body, o.Type)
+ }
+ value, err := output.ParseValue(body)
+ if err != nil {
+ return err
+ }
+ counts, _ := value.(*jsyaml.Map)
+ if counts == nil || counts.Len() == 0 {
+ fmt.Println("No targets found.")
+ return nil
+ }
+ // The platform sends the types in no particular order.
+ types := counts.Keys()
+ sort.Strings(types)
+ t := table.New(
+ table.Column{Name: "type", Title: "Target type", Alignment: table.Left},
+ table.Column{Name: "count", Title: "Targets", Alignment: table.Right},
+ )
+ for _, kind := range types {
+ count, _ := counts.Get(kind)
+ t.AddRow(table.Default, table.Cell("type", kind), table.Cell("count", count))
+ }
+ t.Print()
+ return nil
+}
diff --git a/internal/target/stats_test.go b/internal/target/stats_test.go
new file mode 100644
index 0000000..87aeff3
--- /dev/null
+++ b/internal/target/stats_test.go
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package target_test
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/steadybit/cli/v6/internal/output"
+ "github.com/steadybit/cli/v6/internal/platformtest"
+ "github.com/steadybit/cli/v6/internal/target"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const stats = `{"com.steadybit.extension_kubernetes.kubernetes-pod":11108,"com.steadybit.extension_aws.zone":12,"com.steadybit.extension_host.host":25}`
+
+func TestStatsCountsEveryTypeInOrder(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/target-stats", platformtest.Reply{Body: stats})
+
+ out, err := platformtest.Stdout(t, func() error { return target.Stats(ctx, p.Client, target.StatsOptions{}) })
+
+ require.NoError(t, err)
+ assert.Contains(t, out, "│ com.steadybit.extension_aws.zone │ 12 │\n"+
+ "│ com.steadybit.extension_host.host │ 25 │\n"+
+ "│ com.steadybit.extension_kubernetes.kubernetes-pod │ 11108 │")
+}
+
+func TestStatsOfAQuery(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("POST /api/target-stats", platformtest.Reply{Body: `{"com.steadybit.extension_host.host":3}`})
+
+ out, err := platformtest.Stdout(t, func() error {
+ return target.Stats(ctx, p.Client, target.StatsOptions{Query: `k8s.namespace="shop"`, Type: "yaml"})
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "com.steadybit.extension_host.host: 3\n", out)
+ assert.Equal(t, map[string]any{"query": `k8s.namespace="shop"`}, p.Requests("POST /api/target-stats")[0].JSON(t))
+}
+
+func TestStatsPrintsThePlatformsValue(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/target-stats", platformtest.Reply{Body: stats})
+
+ json, err := platformtest.Stdout(t, func() error { return target.Stats(ctx, p.Client, target.StatsOptions{Type: "json"}) })
+ require.NoError(t, err)
+ output.JQ = `.["com.steadybit.extension_host.host"]`
+ t.Cleanup(func() { output.JQ = "" })
+ jq, err := platformtest.Stdout(t, func() error { return target.Stats(ctx, p.Client, target.StatsOptions{}) })
+ require.NoError(t, err)
+
+ assert.Equal(t, "{\n \"com.steadybit.extension_kubernetes.kubernetes-pod\": 11108,\n \"com.steadybit.extension_aws.zone\": 12,\n \"com.steadybit.extension_host.host\": 25\n}\n", json)
+ assert.Equal(t, "25\n", jq)
+}
+
+func TestStatsSaysWhenNothingMatches(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("POST /api/target-stats", platformtest.Reply{Body: `{}`})
+
+ out, err := platformtest.Stdout(t, func() error { return target.Stats(ctx, p.Client, target.StatsOptions{Query: `k8s.namespace="none"`}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "No targets found.\n", out)
+}
+
+func TestStatsReportsAnInvalidQuery(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("POST /api/target-stats", platformtest.Reply{Status: http.StatusUnprocessableEntity,
+ Body: `{"title":"Constraint Violation","status":422,"violations":[{"field":"query","message":"Failed to parse query"}]}`})
+
+ err := target.Stats(ctx, p.Client, target.StatsOptions{Query: "k8s.namespace="})
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "Failed to get the target statistics: ")
+ assert.Contains(t, err.Error(), "Failed to parse query")
+}
+
+func TestStatsRejectsAWrongTypeBeforeAnyRequest(t *testing.T) {
+ p := platformtest.New(t)
+
+ // No route: a request would fail the test.
+ err := target.Stats(ctx, p.Client, target.StatsOptions{Type: "xml"})
+
+ assert.EqualError(t, err, `unsupported output format 'xml'. Use "json" or "yaml"`)
+}