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.