Skip to content

Build a CLI for your SaaS with Rungrad

By Vincent Schmalbach · Updated

If you run a SaaS app, a CLI lets customers use it from their terminal. They can export data, create resources from scripts or call it from a CI job. An AI agent can use the same commands.

This guide starts with a small API and connects a Go CLI to it. Then it covers adding writes, configuration, checks and a release. Rungrad handles the CLI behavior. Your application still owns its API, permissions and data.

1. Choose the first task

Start with one thing a customer wants to do. For this example, it is listing projects. Later, you can add creating and deleting projects:

acmectl project list
acmectl project create demo
acmectl project delete prj_123

Keep the names close to the words in your app. Use stable IDs when changing a resource. If you accept names too, return matching candidates when the name is ambiguous instead of choosing one.

2. Give the task an API

Your backend can be written in any language. If it already has a public API, use that. Otherwise, add endpoints over the same application services your web interface uses. Keep authorization on the server; a CLI is another client.

A first API contract could look like this:

Request Result
GET /v1/projects 200 with a JSON array of projects
POST /v1/projects 201 with the created project
DELETE /v1/projects/{id} 204 after deleting the project

A project has an id and a name. List results use stable ordering. For a larger list, add pagination and document how to get the next page. Return errors with an HTTP status and a code the client can handle. Use 401 for an invalid token and 403 when that token cannot perform the action.

Before connecting your app, this local Go server gives you a read-only fixture. Save it as api.go in a separate directory:

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "os"
)

func main() {
    token := os.Getenv("ACME_API_TOKEN")
    if token == "" {
        log.Fatal("set ACME_API_TOKEN")
    }
    mux := http.NewServeMux()
    mux.HandleFunc("GET /v1/projects", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        if r.Header.Get("Authorization") != "Bearer " + token {
            w.WriteHeader(http.StatusUnauthorized)
            json.NewEncoder(w).Encode(map[string]string{"code": "unauthorized"})
            return
        }
        json.NewEncoder(w).Encode([]map[string]string{
            {"id": "prj_123", "name": "demo"},
        })
    })
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", mux))
}

Run it in one terminal:

ACME_API_TOKEN=local-demo-token go run api.go

This server only lists fixture data. It has no database or write endpoints. For your deployed API, use HTTPS and your application’s token validation and permissions. Do not reuse the demonstration token.

3. Create the CLI project

Use Go 1.22.2 or newer. In another directory:

go install github.com/vincentsch/rungrad/cmd/[email protected]
rungrad new acmectl
cd acmectl
go mod tidy

The generated widget commands are an offline example, not a connection to your SaaS. Keep them as a reference while replacing them. For a larger starting project, rungrad new acmectl --product-profile also adds configuration, service endpoints and profile hooks. See the product-profile options.

4. Connect a read command

For this first command, replace the generated main.go with the code below. Remove the generated main_test.go too: it tests the widget application you are replacing. Keep the rest of the module. Add your own command tests as described below.

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"
    "time"

    "github.com/spf13/cobra"
    "github.com/vincentsch/rungrad"
)

type Project struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

func main() {
    app := rungrad.New(rungrad.AppConfig{
        Name: "acmectl", Short: "Use Acme from the terminal",
        Version: "v0.1.0", EnvVar: "ACME_TOKEN",
    })
    project := &rungrad.Command{Use: "project", Short: "Work with projects"}
    project.AddCommand(&rungrad.Command{
        Use: "list", Short: "List projects", Args: cobra.NoArgs,
        Examples: []string{"acmectl project list", "acmectl project list --json"},
        Related: []string{"acmectl project --help"},
        OutputModes: []string{rungrad.OutputModeHuman, rungrad.OutputModeJSON},
        RequiresAuth: true,
        Run: func(f *rungrad.Factory, cmd *cobra.Command, args []string) error {
            baseURL := os.Getenv("ACME_API_URL")
            if baseURL == "" {
                baseURL = "http://127.0.0.1:8080"
            }
            req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet,
                strings.TrimRight(baseURL, "/") + "/v1/projects", nil)
            if err != nil {
                return err
            }
            req.Header.Set("Authorization", "Bearer " + f.Token)
            client := &http.Client{Timeout: 15 * time.Second}
            resp, err := client.Do(req)
            if err != nil {
                return err
            }
            defer resp.Body.Close()
            switch resp.StatusCode {
            case http.StatusUnauthorized:
                return rungrad.NewError(rungrad.ExitAuth, "invalid API token")
            case http.StatusForbidden:
                return rungrad.NewError(rungrad.ExitForbidden, "project access denied")
            case http.StatusTooManyRequests:
                return rungrad.NewError(rungrad.ExitRateLimited, "API rate limit reached")
            }
            if resp.StatusCode != http.StatusOK {
                return rungrad.NewError(rungrad.ExitAPI,
                    fmt.Sprintf("API returned HTTP %d", resp.StatusCode))
            }
            projects := []Project{}
            if err := json.NewDecoder(resp.Body).Decode(&projects); err != nil {
                return err
            }
            return f.WriteResult(projects, func(w io.Writer) {
                for _, p := range projects {
                    fmt.Fprintf(w, "%s\t%s\n", p.ID, p.Name)
                }
            })
        },
    })
    app.AddCommand(project)
    os.Exit(app.Run(os.Args[1:], os.Stdout, os.Stderr))
}

This example uses Go’s HTTP client with a timeout and the command’s context. It reads a token through Rungrad’s auth hook, then passes the response to one output function. --json returns the project data; the default output prints an ID and name per line.

Call the local fixture:

go build -o acmectl .
ACME_TOKEN=local-demo-token ./acmectl project list
ACME_TOKEN=local-demo-token ./acmectl project list --json

The loopback API URL is a local demonstration default. Replace it with your service’s HTTPS URL before distributing the CLI. Rungrad registers the resolved token for redaction. Register other secrets with f.RegisterSecret before passing them through framework output. Keep raw response bodies out of errors.

5. Add create and delete commands

Keep HTTP requests in an API client once you have more than one command. Command handlers can then validate arguments, check preview or confirmation flags, call that client and render the result.

For create, declare Mutates: true. Build the request body from the arguments, but check f.DryRun() before sending it. This fragment belongs inside the create handler; it does not add an endpoint to the local fixture:

if f.DryRun() {
    return f.WritePreview(output.DryRunPreview{
        Method: "POST", Path: "/v1/projects",
        Body: []output.Field{{Name: "name", Value: args[0]}},
    })
}
// Send the POST request here, then return the created project with f.WriteResult.

Import github.com/vincentsch/rungrad/output for the preview types. For delete, declare Destructive: true and register a local boolean --confirm flag. Return the dry-run preview first. Then call f.ConfirmDestructive before the DELETE request. The destructive-command example shows the flag registration and handler together.

The command metadata alone does not prevent a write. Your handler must return before the HTTP call when previewing or refusing confirmation. Scripts that intend to delete can pass --confirm; an unconfirmed machine-mode call should fail without waiting for input.

6. Make authentication and failures usable

For the first version, a token environment variable is enough. Give customers a way to create and revoke tokens in your app. A later login command can save a credential for the chosen profile. The auth reference explains the hooks and file paths; Rungrad does not supply your login protocol.

Map API failures to the exit-code contract. A script should be able to tell an invalid credential from a missing project or rate limit without parsing an English message. Keep logs and diagnostics on stderr so stdout remains parseable under --json.

Do not retry a create request just because the connection timed out. The server may have created the project before the connection broke. If you need retries, add an idempotency key to your API and have the CLI reuse it for the same action.

7. Check the commands in CI

Test against a local HTTP fixture. Cover successful reads, missing credentials, API errors and malformed responses. Once you add writes, check that previews and refused confirmations send no write requests.

The scorer runs a built executable in an isolated config home without your credentials. For the read-only CLI above, check the missing-credential behavior:

rungrad score ./acmectl --auth "project list" --strict

This check does not need the API server. It checks that the command fails with the authentication exit code when there is no token. It does not check a successful read. For that, use command tests with a local HTTP fixture, or a dedicated scorer fixture build whose auth hook supplies a known local token. The scorer does not inherit ACME_TOKEN or ACME_API_URL from your shell. See conformance for isolation and fixture flags.

When create and delete exist, add fixtures for them too. Run the checks against the built binary in CI. Omitted behaviors are not-applicable; --strict fails required failures but does not enforce full coverage. Scoring complements your API and permission tests.

8. Ship the binary and explain the first task

Build release binaries for the operating systems and architectures you support. Tag the release, publish checksums and show one installation command. Keep the CLI version visible in --version so a bug report can name the release.

Start your customer docs with the task from step 1: get a token, list projects, and read the same result as JSON. Add create and delete examples when those commands are implemented. The CLI behavior spec is a checklist for keeping those commands consistent as the tool grows.