Skip to content
Open
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
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,29 @@ npx skills add customerio/cli

## Authentication

The CLI uses **service account tokens** for authentication. Production tokens use `sa_live_...`; Builder sandbox signup can temporarily return `sa_sandbox_...` until go-live. These are exchanged for short-lived JWTs via OAuth 2.0 client credentials, just like `gh auth`.
The CLI uses **service account tokens** (`sa_live_...`) for authentication. These are exchanged for short-lived JWTs via OAuth 2.0 client credentials, just like `gh auth`.

### New to Customer.io?

Create an account from the CLI. `start` emails you a 6-digit code, and
`verify` checks it, creates the account and saves its credentials, so you
don't need to log in afterward.

```bash
cio auth signup start --json '{"email":"you@example.com"}'

cio auth signup verify --json '{
"email": "you@example.com",
"code": "123456",
"company_name": "Acme",
"first_name": "Ada",
"last_name": "Lovelace",
"data_center": "us"
}'
```

`data_center` is `us` or `eu`. AI agents can run this for the user; the full
flow is in `cio skills read cio/auth.md`.

### Login

Expand Down Expand Up @@ -128,7 +150,7 @@ which a per-command `--profile` / `CIO_PROFILE` override does not change.

### How It Works

1. You provide a service account token (`sa_live_...` from Customer.io UI → Account Settings → Manage API Credentials → Service Accounts, or `sa_sandbox_...` returned by Builder sandbox signup)
1. You provide a service account token (`sa_live_...` from Customer.io UI → Account Settings → Manage API Credentials → Service Accounts), or `cio auth signup verify` saves one for a new account
2. The CLI exchanges it for a JWT via `POST /v1/service_accounts/oauth/token`
3. The JWT is cached locally and refreshed automatically when it expires
4. All API calls use `Authorization: Bearer <jwt>`
Expand Down
57 changes: 53 additions & 4 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/customerio/cli/internal/client"
"github.com/customerio/cli/internal/clipboard"
"github.com/customerio/cli/internal/output"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
"golang.org/x/term"
)
Expand All @@ -21,6 +22,10 @@ var isTerminalInput = func(fd uintptr) bool {
return term.IsTerminal(int(fd))
}

// isCygwinTerminalInput reports a mintty, MSYS or Cygwin pty. Windows gives
// one as a named pipe, which term.IsTerminal doesn't count as a terminal.
var isCygwinTerminalInput = isatty.IsCygwinTerminal

var readPasswordInput = func(fd uintptr) ([]byte, error) {
return term.ReadPassword(int(fd))
}
Expand Down Expand Up @@ -64,7 +69,7 @@ func clipboardToken(cmd *cobra.Command, wait bool) (string, error) {
if token == "" {
err := fmt.Errorf("clipboard is empty")
output.PrintError(output.CodeValidationError, err.Error(), map[string]any{
"hint": "Copy the token from the token page, then re-run. In a remote shell your local clipboard isn't visible — run 'cio auth login' in your own terminal instead.",
"hint": "Copy the token from the token page, then re-run, or add --wait to start watching the clipboard before the token is copied. If the token was already copied, this shell can't see your local clipboard (SSH, containers), so run 'cio auth login' in your own terminal instead.",
})
return "", err
}
Expand All @@ -82,7 +87,7 @@ func clipboardToken(cmd *cobra.Command, wait bool) (string, error) {
if time.Now().After(deadline) {
err := fmt.Errorf("timed out waiting for a token on the clipboard")
output.PrintError(output.CodeValidationError, err.Error(), map[string]any{
"hint": "Copy the token from the token page and re-run. In a remote shell your local clipboard isn't visible — run 'cio auth login' in your own terminal instead.",
"hint": "Re-run the same command to keep waiting. If the token was already copied, this shell can't see your local clipboard (SSH, containers), so run 'cio auth login' in your own terminal instead.",
})
return "", err
}
Expand All @@ -100,7 +105,10 @@ var authCmd = &cobra.Command{
Long: `Manage authentication for the Customer.io CLI.

Credentials are stored in ~/.cio/config.json.
You can also set the CIO_TOKEN environment variable or pass --token on any command.`,
You can also set the CIO_TOKEN environment variable or pass --token on any command.

AI agents can sign the user in: see 'cio auth login --help'. If the user
has no Customer.io account, 'cio auth signup' creates one.`,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
Expand All @@ -115,6 +123,19 @@ var authLoginCmd = &cobra.Command{
Short: "Authenticate the Customer.io CLI",
Long: `Sign in to the Customer.io CLI.

For AI agents: you can run this login for the user. Start the waiting
login and, in the same message, give the user these steps (they may not
see this command's output):
$ cio auth login --from-clipboard --wait
1. I've started a login command that watches your clipboard.
2. Open https://fly.customer.io/cli and sign in.
3. Copy the token shown. It's picked up automatically.
The command waits up to 90 seconds. On timeout, re-run it right away
without ending your turn. Never have the user paste a token into the
conversation. Plain 'cio auth login' asks for a pasted token when none is
stored, so it fails without a terminal. For signup, remote shells and EU
accounts, run 'cio skills read cio/auth.md'.

If you're already signed in, this prints a link to open Customer.io in
your browser — no password needed.

Expand Down Expand Up @@ -180,6 +201,16 @@ re-authenticates whichever profile is currently selected (see 'cio profile').`,
return runLoginCLILink(cmd, existing)
}

// Without a terminal, as in an agent's shell, stdin may never
// close, so the prompt would hang. Say what works instead.
if !canPromptForToken(cmd.InOrStdin()) {
err := fmt.Errorf("no terminal to paste a token into")
output.PrintError(output.CodeValidationError, err.Error(), map[string]any{
"hint": "Run 'cio auth login --from-clipboard --wait' and have the user copy the token from the token page (see 'cio auth login --help'). To pipe a token in, use 'cio auth login --with-token'.",
})
return err
}

// Print the URL rather than shelling out to a browser — works
// under SSH, headless CI, and restrictive sandboxes.
loginURL := resolveCLILoginURL(resolveLoginBaseURL(cmd))
Expand Down Expand Up @@ -290,6 +321,11 @@ var authLogoutCmd = &cobra.Command{
// auth status
// ---------------------------------------------------------------------------

// noTokenHint follows the no-token errors from auth status and auth token.
// Agents run auth status first, so it names signup for a user with no account
// as well as the agent login for one who has an account.
const noTokenHint = "If the user has no Customer.io account, create one with 'cio auth signup start' (see 'cio auth signup --help'). For an existing account, run 'cio auth login --from-clipboard --wait' (see 'cio auth login --help')."

var authStatusCmd = &cobra.Command{
Use: "status",
Short: "Display active authentication state",
Expand Down Expand Up @@ -325,6 +361,7 @@ Token resolution order:
err := fmt.Errorf("not authenticated. Run 'cio auth login' or set CIO_TOKEN")
output.PrintError(output.CodeAuthError, err.Error(), map[string]any{
"token_source": "none",
"hint": noTokenHint,
})
return err
}
Expand Down Expand Up @@ -398,7 +435,9 @@ Token resolution order:

if token == "" {
err := fmt.Errorf("no token found. Run 'cio auth login' or set CIO_TOKEN")
output.PrintError(output.CodeAuthError, err.Error(), nil)
output.PrintError(output.CodeAuthError, err.Error(), map[string]any{
"hint": noTokenHint,
})
return err
}

Expand Down Expand Up @@ -716,6 +755,16 @@ func resolveLoginAPIURL(cmd *cobra.Command) string {
return ""
}

// canPromptForToken reports whether plain login can prompt for a pasted token,
// which needs a terminal. Agent shells give stdin as /dev/null or as a pipe or
// socket that never closes, and piped tokens go through --with-token. A reader
// that isn't a file, as in tests, is read as is. A Git Bash or Cygwin pty
// counts as a terminal, and its prompt reads the pasted line.
func canPromptForToken(input io.Reader) bool {
f, ok := input.(*os.File)
return !ok || isTerminalInput(f.Fd()) || isCygwinTerminalInput(f.Fd())
}

func readInteractiveToken(input io.Reader, stderr io.Writer) (string, error) {
var (
fd uintptr
Expand Down
146 changes: 144 additions & 2 deletions cmd/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,73 @@ func TestReadInteractiveTokenWithTTY_FallsBackForNonTerminalInput(t *testing.T)
}
}

func TestAuthLogin_NoTerminalFailsFast(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
t.Setenv("CIO_TOKEN", "")

devNull, err := os.Open(os.DevNull)
if err != nil {
t.Fatalf("open %s: %v", os.DevNull, err)
}
defer devNull.Close()
rootCmd.SetIn(devNull)
t.Cleanup(func() { rootCmd.SetIn(nil) })

_, stderr, err := executeCommand("auth", "login")
if err == nil || !strings.Contains(err.Error(), "no terminal to paste a token into") {
t.Fatalf("expected the no-terminal error, got: %v", err)
}
if strings.Contains(stderr, "Paste token:") || strings.Contains(stderr, "Open this URL") {
t.Errorf("expected no paste prompt, got stderr:\n%s", stderr)
}
if _, err := os.Stat(filepath.Join(tmpDir, ".cio", "config.json")); !os.IsNotExist(err) {
t.Errorf("expected no config file, stat err: %v", err)
}
}

func TestCanPromptForToken(t *testing.T) {
devNull, err := os.Open(os.DevNull)
if err != nil {
t.Fatalf("open %s: %v", os.DevNull, err)
}
defer devNull.Close()
if canPromptForToken(devNull) {
t.Errorf("expected no prompt on %s", os.DevNull)
}

// An agent's shell can hold stdin open without ever writing to it.
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer r.Close()
defer w.Close()
if canPromptForToken(r) {
t.Errorf("expected no prompt on a pipe")
}

if !canPromptForToken(strings.NewReader("sa_live_piped\n")) {
t.Errorf("expected a non-file reader to be read as is")
}

// Git Bash's mintty and Cygwin give stdin as a named pipe.
origIsCygwinTerminal := isCygwinTerminalInput
t.Cleanup(func() { isCygwinTerminalInput = origIsCygwinTerminal })
isCygwinTerminalInput = func(fd uintptr) bool { return true }
if !canPromptForToken(r) {
t.Errorf("expected a prompt on a Cygwin or MSYS pty")
}
isCygwinTerminalInput = origIsCygwinTerminal

origIsTerminal := isTerminalInput
t.Cleanup(func() { isTerminalInput = origIsTerminal })
isTerminalInput = func(fd uintptr) bool { return true }
if !canPromptForToken(devNull) {
t.Errorf("expected a prompt on a terminal")
}
}

// When `cio auth login` runs with no args and a sa_live_ already saved, it
// should hit /v1/login_cli/link on the API, get a handoff JWT back, and
// print a one-click URL — without touching the existing stored token.
Expand Down Expand Up @@ -656,6 +723,27 @@ func TestAuthLogin_HelpMentionsBrowserFlow(t *testing.T) {
}
}

func TestAuthLogin_HelpTellsAgentsToRunIt(t *testing.T) {
help := authLoginCmd.Long
for _, want := range []string{
"For AI agents: you can run this login for the user.",
"$ cio auth login --from-clipboard --wait",
"Open https://fly.customer.io/cli and sign in.",
"The command waits up to 90 seconds.",
"cio skills read cio/auth.md",
} {
if !strings.Contains(help, want) {
t.Errorf("expected login help to contain %q, got:\n%s", want, help)
}
}
}

func TestAuth_HelpNamesSignup(t *testing.T) {
if !strings.Contains(authCmd.Long, "'cio auth signup'") {
t.Errorf("expected auth help to name signup, got:\n%s", authCmd.Long)
}
}

func TestAuthLogout_RemovesCredentials(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
Expand Down Expand Up @@ -701,10 +789,60 @@ func TestAuthStatus_NotAuthenticated(t *testing.T) {
t.Setenv("HOME", tmpDir)
t.Setenv("CIO_TOKEN", "")

_, _, err := executeCommand("auth", "status")
var err error
stderr := captureStderr(t, func() {
_, _, err = executeCommand("auth", "status")
})
if err == nil {
t.Fatal("expected error when not authenticated")
}
assertNoTokenHint(t, stderr)
}

// assertNoTokenHint checks that a no-token error names signup for a user with
// no account and the agent login for one who has an account.
func assertNoTokenHint(t *testing.T, stderr string) {
t.Helper()
var e struct {
Code string `json:"code"`
Details map[string]any `json:"details"`
}
line, _, _ := strings.Cut(stderr, "\n")
if err := json.Unmarshal([]byte(line), &e); err != nil {
t.Fatalf("expected a JSON error on stderr: %v\nstderr: %s", err, stderr)
}
if e.Code != "AUTH_ERROR" {
t.Errorf("expected AUTH_ERROR, got %q", e.Code)
}
hint, _ := e.Details["hint"].(string)
for _, want := range []string{"cio auth signup start", "cio auth login --from-clipboard --wait"} {
if !strings.Contains(hint, want) {
t.Errorf("expected hint to contain %q, got %q", want, hint)
}
}
}

// captureStderr runs fn with os.Stderr redirected to a pipe and returns what
// was written. output.PrintError writes to os.Stderr, not the command's writer.
func captureStderr(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
old := os.Stderr
os.Stderr = w
defer func() { os.Stderr = old }()

done := make(chan string)
go func() {
b, _ := io.ReadAll(r)
done <- string(b)
}()

fn()
_ = w.Close()
return <-done
}

func TestAuthStatus_WithEnvToken(t *testing.T) {
Expand Down Expand Up @@ -844,10 +982,14 @@ func TestAuthToken_NoToken(t *testing.T) {
t.Setenv("HOME", tmpDir)
t.Setenv("CIO_TOKEN", "")

_, _, err := executeCommand("auth", "token")
var err error
stderr := captureStderr(t, func() {
_, _, err = executeCommand("auth", "token")
})
if err == nil {
t.Fatal("expected error when no token available")
}
assertNoTokenHint(t, stderr)
}

// signupServer returns a test server for the agentic signup endpoints.
Expand Down
2 changes: 1 addition & 1 deletion cmd/billing_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ func forecastWallet(now time.Time, usage walletUsage, rates map[string]float64)
funds.RunsOutOn = dateOf(now)
fundsWarning = &billingWarning{
Code: "FUNDS_LOW",
Message: "No funds left. Usage beyond the free grants fails: metered sends are undeliverable and new active profiles are dropped. Run 'cio billing add-funds' to add funds.",
Message: "No funds left. New active profiles beyond the free grant are dropped, and metered sends may fail. Run 'cio billing add-funds' to add funds.",
}
} else {
slices.SortFunc(later, func(a, b rollover) int { return cmp.Compare(a.day, b.day) })
Expand Down
9 changes: 8 additions & 1 deletion cmd/bootstrap_skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,11 @@ cio prime
task-specific skills (`cio skills read <skill>`), global flags, exit codes,
and examples. Follow what it returns.

If `cio` is not installed, see https://github.com/customerio/cli.
If the user has no Customer.io account, create one with `cio auth signup`.
`cio prime` explains the flow.

If `cio` is not installed, install it with npm:

```bash
npm install -g @customerio/cli
```
Loading
Loading