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
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,25 @@ rm -rf ~/.cio

## Install the agent skill

This repo ships a [SKILL.md](skills/cio/SKILL.md) so Claude Code, Cursor, Codex, Windsurf, and other agents that support [open agent skills](https://github.com/vercel-labs/skills) know how to drive the CLI. Install it with:
This repo ships a [SKILL.md](skills/cio/SKILL.md) so Claude Code, Cursor, Codex, Windsurf, and other agents that support [open agent skills](https://github.com/vercel-labs/skills) know how to drive the CLI.

The CLI can install the current skills directly:

```bash
# Installs the bootstrap skill; prompts for global (~/.claude, ~/.agents)
# vs. project (./.claude, ./.agents)
cio skills install

cio skills install --global # install for every project
cio skills install --project # install into the current directory
cio skills install --target claude # Claude Code only (use --target codex for Codex)
cio skills install --dry-run # show what would be written
cio skills install --force # overwrite an existing SKILL.md
```

This installs only the Customer.io bootstrap skill — its `SKILL.md` routing index is written to `<target>/skills/<name>/` for Claude Code (`.claude`) and the open agent skills convention (`.agents`) Codex, Cursor, and Windsurf read. Every other reference (Journeys, CDP, Design Studio, recipes) is served by the backend and pulled on demand via `cio skills read <skill>`, so nothing else is copied locally and the content stays current.

Or install via the open agent skills tooling:

```bash
npx skills add customerio/cli
Expand Down
94 changes: 94 additions & 0 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"github.com/customerio/cli/internal/client"
"github.com/customerio/cli/internal/clipboard"
"github.com/customerio/cli/internal/output"
"github.com/spf13/cobra"
"golang.org/x/term"
Expand All @@ -24,6 +25,75 @@ var readPasswordInput = func(fd uintptr) ([]byte, error) {
return term.ReadPassword(int(fd))
}

var readClipboard = clipboard.Read

// Clipboard polling knobs; shrunk in tests. The wait budget stays under
// typical agent tool-call timeouts (~2m) so a timed-out wait returns a clean
// error the caller can react to instead of being killed mid-poll.
var (
clipboardPollInterval = time.Second
clipboardWaitBudget = 90 * time.Second
)

// clipboardToken reads a service-account token from the system clipboard.
//
// With wait=true it polls until a token-shaped value appears or the budget
// runs out, so the command can be started before the user has copied the
// token — no confirmation round-trip. While polling, non-token clipboard
// contents are ignored; they are never echoed, stored, or sent anywhere.
func clipboardToken(cmd *cobra.Command, wait bool) (string, error) {
if wait {
fmt.Fprintf(cmd.ErrOrStderr(), "Sign in and copy your token from:\n\n %s\n\nWaiting for it on your clipboard (Ctrl-C cancels)...\n", resolveCLILoginURL())
}
deadline := time.Now().Add(clipboardWaitBudget)
for {
text, err := readClipboard(cmd.Context())
if err != nil {
err = fmt.Errorf("could not read the clipboard: %w", err)
output.PrintError(output.CodeGeneralError, err.Error(), map[string]any{
"hint": "Remote shells (SSH, containers) can't see your local clipboard. Run 'cio auth login' in your own terminal, or pipe the token: pbpaste | cio auth login --with-token",
})
return "", err
}
token := strings.TrimSpace(text)
if client.IsServiceAccountToken(token) {
return token, nil
}

if !wait {
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.",
})
return "", err
}
// Unlike the generic token validation, don't echo any of the
// input — whatever is on the clipboard may be unrelated and
// sensitive.
err := fmt.Errorf("clipboard contents don't look like a service account token (expected a %q or %q prefix)",
client.ServiceAccountTokenPrefix, client.SandboxServiceAccountTokenPrefix)
output.PrintError(output.CodeValidationError, err.Error(), map[string]any{
"hint": "Copy the token last — it must be the most recent thing on your clipboard — then re-run.",
})
return "", err
}

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.",
})
return "", err
}
select {
case <-cmd.Context().Done():
return "", cmd.Context().Err()
case <-time.After(clipboardPollInterval):
}
}
}

var authCmd = &cobra.Command{
Use: "auth",
Short: "Authenticate Customer.io CLI with the Customer.io API",
Expand Down Expand Up @@ -51,19 +121,40 @@ your browser — no password needed.
If this is your first time, you'll be guided to sign in at
fly.customer.io and paste a token back into your terminal.

After copying a token from the token page, you can read it straight from
your clipboard without pasting:
$ cio auth login --from-clipboard

Add --wait to start the command first and have it pick the token up the
moment you copy it:
$ cio auth login --from-clipboard --wait

For CI or non-interactive use:
$ echo "$TOKEN" | cio auth login --with-token
$ cio auth login <token>`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
withToken, _ := cmd.Flags().GetBool("with-token")
fromClipboard, _ := cmd.Flags().GetBool("from-clipboard")
waitForClipboard, _ := cmd.Flags().GetBool("wait")

if waitForClipboard && !fromClipboard {
err := fmt.Errorf("--wait requires --from-clipboard")
output.PrintError(output.CodeValidationError, err.Error(), nil)
return err
}

var (
token string
err error
)

switch {
case fromClipboard:
token, err = clipboardToken(cmd, waitForClipboard)
if err != nil {
return err
}
case withToken:
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
Expand Down Expand Up @@ -615,6 +706,9 @@ func resolveSignupBaseURL(cmd *cobra.Command) string {

func init() {
authLoginCmd.Flags().Bool("with-token", false, "Read token from standard input")
authLoginCmd.Flags().Bool("from-clipboard", false, "Read token from the system clipboard")
authLoginCmd.Flags().Bool("wait", false, "With --from-clipboard: poll until a token appears on the clipboard")
authLoginCmd.MarkFlagsMutuallyExclusive("with-token", "from-clipboard")

authSignupCmd.AddCommand(authSignupStartCmd)
authSignupCmd.AddCommand(authSignupVerifyCmd)
Expand Down
175 changes: 175 additions & 0 deletions cmd/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
Expand All @@ -14,6 +15,7 @@ import (
"time"

"github.com/customerio/cli/internal/client"
"github.com/customerio/cli/internal/clipboard"
)

func executeCommand(args ...string) (stdout, stderr string, err error) {
Expand All @@ -38,6 +40,15 @@ func executeCommand(args ...string) (stdout, stderr string, err error) {
_ = apiCmd.Flags().Set("method", "")
}

// Reset auth login flags; Changed must clear too, or the
// mutually-exclusive-flags check sees stale state from earlier tests.
for _, name := range []string{"with-token", "from-clipboard", "wait"} {
if f := authLoginCmd.Flags().Lookup(name); f != nil {
_ = authLoginCmd.Flags().Set(name, "false")
f.Changed = false
}
}

// Reset send/transactional persistent flags.
for _, name := range []string{"environment-id"} {
if f := sendCmd.PersistentFlags().Lookup(name); f != nil {
Expand Down Expand Up @@ -956,3 +967,167 @@ func TestAuthSignupStart_DryRun(t *testing.T) {
t.Errorf("unexpected url: %v", result["url"])
}
}

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

orig := readClipboard
defer func() { readClipboard = orig }()
readClipboard = func(context.Context) (string, error) { return "sa_live_test123\n", nil }

server := oauthServer(t, "sa_live_test123")
defer server.Close()

stdout, _, err := executeCommand("auth", "login", "--from-clipboard", "--api-url", server.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var result map[string]any
if err := json.Unmarshal([]byte(stdout), &result); err != nil {
t.Fatalf("invalid JSON output: %v\nstdout: %s", err, stdout)
}
if result["status"] != "ok" {
t.Errorf("expected status ok, got %v", result["status"])
}

data, err := os.ReadFile(filepath.Join(tmpDir, ".cio", "config.json"))
if err != nil {
t.Fatalf("failed to read config file: %v", err)
}
var creds map[string]any
if err := json.Unmarshal(data, &creds); err != nil {
t.Fatalf("invalid JSON in config file: %v", err)
}
if creds["service_account_token"] != "sa_live_test123" {
t.Errorf("expected sa_live_test123, got %v", creds["service_account_token"])
}
}

func TestAuthLogin_FromClipboard_Empty(t *testing.T) {
orig := readClipboard
defer func() { readClipboard = orig }()
readClipboard = func(context.Context) (string, error) { return " \n", nil }

_, _, err := executeCommand("auth", "login", "--from-clipboard")
if err == nil {
t.Fatal("expected error for empty clipboard")
}
if !strings.Contains(err.Error(), "clipboard is empty") {
t.Errorf("expected clipboard-empty error, got: %v", err)
}
}

func TestAuthLogin_FromClipboard_BadPrefixDoesNotEchoContents(t *testing.T) {
orig := readClipboard
defer func() { readClipboard = orig }()
readClipboard = func(context.Context) (string, error) { return "hunter2-something-sensitive", nil }

stdout, stderr, err := executeCommand("auth", "login", "--from-clipboard")
if err == nil {
t.Fatal("expected error for non-token clipboard contents")
}
for name, s := range map[string]string{"error": err.Error(), "stdout": stdout, "stderr": stderr} {
if strings.Contains(s, "hunter2") {
t.Errorf("clipboard contents leaked into %s: %s", name, s)
}
}
}

func TestAuthLogin_FromClipboard_NoTool(t *testing.T) {
orig := readClipboard
defer func() { readClipboard = orig }()
readClipboard = func(context.Context) (string, error) { return "", clipboard.ErrNoTool }

_, _, err := executeCommand("auth", "login", "--from-clipboard")
if err == nil {
t.Fatal("expected error when no clipboard tool is available")
}
if !strings.Contains(err.Error(), "could not read the clipboard") {
t.Errorf("expected clipboard-read error, got: %v", err)
}
}

func TestAuthLogin_FromClipboardAndWithTokenAreExclusive(t *testing.T) {
_, _, err := executeCommand("auth", "login", "--from-clipboard", "--with-token")
if err == nil {
t.Fatal("expected mutually-exclusive flag error")
}
}

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

origRead, origInterval, origBudget := readClipboard, clipboardPollInterval, clipboardWaitBudget
defer func() {
readClipboard, clipboardPollInterval, clipboardWaitBudget = origRead, origInterval, origBudget
}()
clipboardPollInterval = time.Millisecond
clipboardWaitBudget = time.Second

// Clipboard holds unrelated content for the first few polls, then the token.
calls := 0
readClipboard = func(context.Context) (string, error) {
calls++
if calls < 3 {
return "https://fly.customer.io/cli", nil
}
return "sa_live_test123\n", nil
}

server := oauthServer(t, "sa_live_test123")
defer server.Close()

stdout, _, err := executeCommand("auth", "login", "--from-clipboard", "--wait", "--api-url", server.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if calls < 3 {
t.Errorf("expected at least 3 clipboard polls, got %d", calls)
}

var result map[string]any
if err := json.Unmarshal([]byte(stdout), &result); err != nil {
t.Fatalf("invalid JSON output: %v\nstdout: %s", err, stdout)
}
if result["status"] != "ok" {
t.Errorf("expected status ok, got %v", result["status"])
}
}

func TestAuthLogin_FromClipboardWait_TimesOutWithoutEchoing(t *testing.T) {
origRead, origInterval, origBudget := readClipboard, clipboardPollInterval, clipboardWaitBudget
defer func() {
readClipboard, clipboardPollInterval, clipboardWaitBudget = origRead, origInterval, origBudget
}()
clipboardPollInterval = time.Millisecond
clipboardWaitBudget = 10 * time.Millisecond
readClipboard = func(context.Context) (string, error) { return "private-notes-not-a-token", nil }

stdout, stderr, err := executeCommand("auth", "login", "--from-clipboard", "--wait")
if err == nil {
t.Fatal("expected timeout error")
}
if !strings.Contains(err.Error(), "timed out waiting") {
t.Errorf("expected timeout error, got: %v", err)
}
for name, s := range map[string]string{"error": err.Error(), "stdout": stdout, "stderr": stderr} {
if strings.Contains(s, "private-notes") {
t.Errorf("clipboard contents leaked into %s: %s", name, s)
}
}
}

func TestAuthLogin_WaitRequiresFromClipboard(t *testing.T) {
_, _, err := executeCommand("auth", "login", "--wait")
if err == nil {
t.Fatal("expected error for --wait without --from-clipboard")
}
if !strings.Contains(err.Error(), "--wait requires --from-clipboard") {
t.Errorf("expected flag-dependency error, got: %v", err)
}
}
16 changes: 16 additions & 0 deletions cmd/bootstrap_skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Customer.io CLI (`cio`)

Customer.io ships an agent-first CLI, `cio`. Before working on anything in
Customer.io — Journeys, CDP Pipelines, Design Studio, transactional messaging,
onboarding, or debugging `fly.customer.io` / `cdp.customer.io` — run:

```bash
cio prime
```

`cio prime` prints the full, current instructions: JSON output rules, the
`cio api` command, schema introspection (`cio schema`), how to read
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.
Loading