From aa38aa3dfb33ee6b500a2874f2dc346d24dc8616 Mon Sep 17 00:00:00 2001 From: "Customer.io Open Source Bot" Date: Sat, 13 Jun 2026 05:50:20 -0700 Subject: [PATCH] Add Customer.io CLI source CioCliPublicExport-RevId: cd7c5ab6b9f01ee09812ac564cceb30b26443032 --- README.md | 20 +- cmd/auth.go | 94 ++++++ cmd/auth_test.go | 175 +++++++++++ cmd/bootstrap_skill.md | 16 + cmd/root.go | 18 +- cmd/root_test.go | 18 ++ cmd/skills.go | 43 ++- cmd/skills_install.go | 431 +++++++++++++++++++++++++++ cmd/skills_install_test.go | 294 ++++++++++++++++++ cmd/skills_test.go | 159 ++++++++-- internal/clipboard/clipboard.go | 63 ++++ internal/clipboard/clipboard_test.go | 77 +++++ internal/skills/skills.go | 60 +++- internal/skills/skills_test.go | 54 ++++ internal/tui/model.go | 55 +++- skills/SKILL-auth.md | 157 ---------- 16 files changed, 1518 insertions(+), 216 deletions(-) create mode 100644 cmd/bootstrap_skill.md create mode 100644 cmd/skills_install.go create mode 100644 cmd/skills_install_test.go create mode 100644 internal/clipboard/clipboard.go create mode 100644 internal/clipboard/clipboard_test.go delete mode 100644 skills/SKILL-auth.md diff --git a/README.md b/README.md index 1d51a7e..e1f256d 100644 --- a/README.md +++ b/README.md @@ -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 `/skills//` 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 `, 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 diff --git a/cmd/auth.go b/cmd/auth.go index 2c9e7b1..9df2ba0 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -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" @@ -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", @@ -51,12 +121,28 @@ 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 `, 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 @@ -64,6 +150,11 @@ For CI or non-interactive use: ) switch { + case fromClipboard: + token, err = clipboardToken(cmd, waitForClipboard) + if err != nil { + return err + } case withToken: scanner := bufio.NewScanner(os.Stdin) if scanner.Scan() { @@ -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) diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 9155e79..e698cce 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -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) { @@ -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 { @@ -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) + } +} diff --git a/cmd/bootstrap_skill.md b/cmd/bootstrap_skill.md new file mode 100644 index 0000000..b655320 --- /dev/null +++ b/cmd/bootstrap_skill.md @@ -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 `), global flags, exit codes, +and examples. Follow what it returns. + +If `cio` is not installed, see https://github.com/customerio/cli. diff --git a/cmd/root.go b/cmd/root.go index cbb63bc..c8d062c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "sort" "strings" "time" @@ -56,7 +57,7 @@ func init() { defaultHelp := rootCmd.HelpFunc() rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { if cmd == rootCmd { - tui.RenderHelp(cmd.OutOrStdout()) + tui.RenderHelp(cmd.OutOrStdout(), topLevelCommands(rootCmd)) return } defaultHelp(cmd, args) @@ -170,6 +171,21 @@ func init() { } } +// topLevelCommands returns the live set of user-facing top-level commands for +// the branded help screen, sorted by name. Cobra's built-in help and +// completion commands are omitted, as are hidden/unavailable ones. +func topLevelCommands(root *cobra.Command) []tui.Command { + var out []tui.Command + for _, c := range root.Commands() { + if !c.IsAvailableCommand() || c.Name() == "help" || c.Name() == "completion" { + continue + } + out = append(out, tui.Command{Name: c.Name(), Desc: c.Short}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + // isAuthCommand returns true for subcommands that don't need the UI API client. func isAuthCommand(cmd *cobra.Command) bool { switch cmd.CommandPath() { diff --git a/cmd/root_test.go b/cmd/root_test.go index b13d9a3..3ce0140 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,11 +1,29 @@ package cmd import ( + "strings" "testing" "github.com/customerio/cli/internal/useragent" ) +func TestRootHelpListsAllCommands(t *testing.T) { + stdout, _, err := executeCommand("--help") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // The branded help screen must enumerate every registered command, not a + // curated subset, so newly added commands can't silently go missing. + if !strings.Contains(stdout, "All commands:") { + t.Fatalf("help missing 'All commands:' section:\n%s", stdout) + } + for _, name := range []string{"api", "auth", "domains", "prime", "schema", "send", "skills", "transactional"} { + if !strings.Contains(stdout, name) { + t.Errorf("help output missing command %q", name) + } + } +} + func TestSetVersionIgnoresEmptyVersion(t *testing.T) { oldRootVersion := rootCmd.Version t.Cleanup(func() { diff --git a/cmd/skills.go b/cmd/skills.go index 7d7c8a8..81983de 100644 --- a/cmd/skills.go +++ b/cmd/skills.go @@ -1,6 +1,8 @@ package cmd import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "strings" @@ -64,15 +66,20 @@ func init() { func loadSkills(cmd *cobra.Command) (*skills.SkillsResponse, error) { refresh, _ := cmd.Flags().GetBool("refresh") - var baseURL string + opts := skills.LoadOptions{ForceRefresh: refresh} + // Best-effort authentication: when credentials are available, send a Bearer + // token so the server scopes the bundle to the account's plan, and key the + // cache by account so a scoped bundle is never reused across accounts. Any + // failure falls through to an unauthenticated fetch (the full bundle). if c := clientFromCmd(cmd); c != nil { - baseURL = c.BaseURL() + opts.BaseURL = c.BaseURL() + if token, err := c.EnsureAccessToken(cmd.Context()); err == nil { + opts.AccessToken = token + opts.CacheScope = accountCacheScope(c.ServiceAccountToken()) + } } - resp, err := skills.EnsureSkills(cmd.Context(), skills.LoadOptions{ - BaseURL: baseURL, - ForceRefresh: refresh, - }) + resp, err := skills.EnsureSkills(cmd.Context(), opts) if err != nil { output.PrintError(output.CodeGeneralError, fmt.Sprintf("failed to load skills: %v", err), nil) return nil, err @@ -83,6 +90,19 @@ func loadSkills(cmd *cobra.Command) (*skills.SkillsResponse, error) { return resp, nil } +// accountCacheScope derives a stable, non-reversible cache key from the active +// service-account token, identifying the account a scoped skills bundle belongs +// to. The access token rotates, so it can't key the cache; the service-account +// token is stable per account. Returns "" when there is no token (the bundle is +// then unscoped and shared). +func accountCacheScope(serviceAccountToken string) string { + if serviceAccountToken == "" { + return "" + } + sum := sha256.Sum256([]byte(serviceAccountToken)) + return hex.EncodeToString(sum[:8]) +} + func runSkillsList(cmd *cobra.Command, args []string) error { resp, err := loadSkills(cmd) if err != nil { @@ -148,17 +168,14 @@ func runSkillsRead(cmd *cobra.Command, args []string) error { }) } - // Look up the sub-file. + // Look up the sub-file. Files outside the account's plan were omitted + // server-side, so an out-of-scope file reads as an unknown file here. content, ok := s.Files[subFile] if !ok { - available := make([]string, 0, len(s.Files)) - for f := range s.Files { - available = append(available, f) - } err := fmt.Errorf("unknown file %q in skill %q", subFile, skillPath) output.PrintError(output.CodeValidationError, err.Error(), map[string]any{ "skill": skillPath, - "available_files": available, + "available_files": s.SortedFiles(), }) return err } @@ -188,6 +205,8 @@ func runSkillsPrompt(cmd *cobra.Command, args []string) error { return err } + // The prompt already carries the account's plan when the bundle was fetched + // with credentials — the server appends it (scoping is resolved server-side). return skillsOutput(cmd, map[string]string{ "prompt": resp.Prompt, }) diff --git a/cmd/skills_install.go b/cmd/skills_install.go new file mode 100644 index 0000000..281551e --- /dev/null +++ b/cmd/skills_install.go @@ -0,0 +1,431 @@ +package cmd + +import ( + "bufio" + "context" + _ "embed" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/customerio/cli/internal/output" + "github.com/customerio/cli/internal/skills" + "github.com/customerio/cli/internal/tui" + "github.com/spf13/cobra" +) + +// bootstrapSkillBody is the SKILL.md body written on install: a thin pointer to +// `cio prime`, which holds the full, current CLI instructions. Keeping the +// installed file minimal avoids shipping a stale copy of guidance that lives +// behind `cio prime` and `cio skills read`. +// +//go:embed bootstrap_skill.md +var bootstrapSkillBody string + +// installTarget describes one agent's on-disk skills layout. The directory is +// resolved relative to the install base (home dir for --global, cwd for +// --project). +type installTarget struct { + // name is the value accepted by --target. + name string + // subdir is the path under the install base where skill folders live. + subdir string +} + +// installTargets maps --target names to their on-disk layout. +// +// - claude — Claude Code reads ~/.claude/skills//SKILL.md (global) or +// ./.claude/skills//SKILL.md (project). +// - codex — Codex, Cursor, Windsurf, and other agents that support the open +// agent skills convention read .agents/skills//SKILL.md. +var installTargets = []installTarget{ + {name: "claude", subdir: filepath.Join(".claude", "skills")}, + {name: "codex", subdir: filepath.Join(".agents", "skills")}, +} + +var skillsInstallCmd = &cobra.Command{ + Use: "install", + Short: "Install the Customer.io bootstrap skill into Claude Code and Codex", + Long: `Write the Customer.io bootstrap skill to disk so Claude Code, Codex, and +other agents discover the CLI: + + Claude Code ~/.claude/skills//SKILL.md (or ./.claude with --project) + Codex/agents ~/.agents/skills//SKILL.md (or ./.agents with --project) + +Only the bootstrap skill is installed. Its SKILL.md routing index tells the +agent to pull every other reference (Journeys, CDP, Design Studio, recipes) +on demand from the backend via 'cio skills read ', so nothing else is +copied locally and the served content is always current. + + cio skills install — install the bootstrap skill (prompts for scope) + cio skills install --global — install into your home directory + cio skills install --project — install into the current directory + cio skills install --target claude — install for Claude Code only + cio skills install --force — overwrite an existing SKILL.md`, + Args: cobra.NoArgs, + RunE: runSkillsInstall, +} + +func init() { + skillsInstallCmd.Flags().Bool("global", false, "Install into your home directory for use across all projects") + skillsInstallCmd.Flags().Bool("project", false, "Install into the current directory only") + skillsInstallCmd.Flags().String("target", "claude,codex", "Comma-separated agents to install for: claude, codex") + skillsInstallCmd.Flags().Bool("force", false, "Overwrite skill files that already exist") + skillsCmd.AddCommand(skillsInstallCmd) +} + +func runSkillsInstall(cmd *cobra.Command, args []string) error { + targets, err := resolveInstallTargets(cmd) + if err != nil { + return err + } + + scope, err := resolveInstallScope(cmd) + if err != nil { + return err + } + + base, err := resolveInstallBase(scope) + if err != nil { + output.PrintError(output.CodeGeneralError, err.Error(), nil) + return err + } + + resp, err := loadSkills(cmd) + if err != nil { + return err + } + + selected, err := selectBootstrap(resp.Skills) + if err != nil { + return err + } + + dryRun := GetDryRun(cmd) + force, _ := cmd.Flags().GetBool("force") + + type installedFile struct { + Skill string `json:"skill"` + Target string `json:"target"` + Dir string `json:"dir"` + Files []string `json:"files"` + } + + installed := make([]installedFile, 0, len(selected)*len(targets)) + for _, s := range selected { + // The skill path and file names come from the server; never let them + // escape the install directory. + if _, err := safeRelPath(s.Path); err != nil { + output.PrintError(output.CodeValidationError, err.Error(), map[string]any{"skill": s.Path}) + return err + } + for _, t := range targets { + dir := filepath.Join(base, t.subdir, s.Path) + files, err := writeSkill(dir, s, dryRun, force) + if err != nil { + output.PrintError(output.CodeGeneralError, err.Error(), map[string]any{ + "skill": s.Path, + "target": t.name, + "dir": dir, + }) + return err + } + installed = append(installed, installedFile{ + Skill: s.Path, + Target: t.name, + Dir: dir, + Files: files, + }) + } + } + + action := "installed" + if dryRun { + action = "would install" + } + return skillsOutput(cmd, map[string]any{ + "status": "ok", + "scope": scope, + "base": base, + "dry_run": dryRun, + "action": action, + "installed": installed, + }) +} + +// writeSkill writes the bootstrap SKILL.md (a thin pointer to `cio prime`) +// into dir, carrying the skill's server-tuned name and description as +// frontmatter. It returns the names of the files written. When dryRun is set +// it reports what would be written without touching disk. When force is false, +// an existing SKILL.md is left untouched. +func writeSkill(dir string, s skills.Skill, dryRun, force bool) ([]string, error) { + const name = "SKILL.md" + + if dryRun { + return []string{name}, nil + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create %s: %w", dir, err) + } + + path := filepath.Join(dir, name) + if !force { + if _, err := os.Stat(path); err == nil { + // File exists and --force not set; leave it untouched. + return nil, nil + } + } + // Write a minimal bootstrap body that points the agent at `cio prime`, + // keeping the server-tuned description for activation. The full guidance is + // served by `cio prime` / `cio skills read`, not copied here. + body := ensureFrontmatter(bootstrapSkillBody, s.Path, s.Description) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + return nil, fmt.Errorf("write %s: %w", path, err) + } + return []string{name}, nil +} + +// ensureFrontmatter guarantees a SKILL.md begins with YAML frontmatter. Agent +// runtimes (Codex / open agent skills, Claude Code) require a `---`-delimited +// block with at least name and description; the server's authored content and +// the synthesized index for entrypoint-less skills don't carry one, so we +// prepend it from the skill's metadata when missing. +func ensureFrontmatter(content, name, description string) string { + if strings.HasPrefix(content, "---\n") || strings.HasPrefix(content, "---\r\n") { + return content + } + var b strings.Builder + fmt.Fprintf(&b, "---\nname: %s\ndescription: %s\n---\n\n", yamlScalar(name), yamlScalar(description)) + b.WriteString(content) + return b.String() +} + +// yamlScalar renders s as a safe single-line double-quoted YAML scalar, +// collapsing internal whitespace (including newlines) so the frontmatter stays +// a valid one-line value. +func yamlScalar(s string) string { + s = strings.Join(strings.Fields(s), " ") + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return `"` + s + `"` +} + +// safeRelPath validates a server-supplied skill path or file name before it is +// used to build a filesystem path. It rejects absolute paths and any name that +// would escape its install directory via "..". +func safeRelPath(name string) (string, error) { + if name == "" { + return "", fmt.Errorf("empty path") + } + clean := filepath.Clean(name) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe path %q", name) + } + return clean, nil +} + +// bootstrapSkillNames are the candidate paths for the entry/bootstrap skill, in +// preference order. The bootstrap is the only skill installed locally; its +// routing index points the agent at every other reference, which is fetched +// from the backend on demand via `cio skills read `. +var bootstrapSkillNames = []string{"cli", "cio"} + +// selectBootstrap returns the single bootstrap skill to install. The other +// skills are intentionally not written to disk — they are served by the API +// and read at runtime so their content stays current. +func selectBootstrap(all []skills.Skill) ([]skills.Skill, error) { + byPath := make(map[string]skills.Skill, len(all)) + available := make([]string, 0, len(all)) + for _, s := range all { + byPath[s.Path] = s + available = append(available, s.Path) + } + for _, want := range bootstrapSkillNames { + if s, ok := byPath[want]; ok { + return []skills.Skill{s}, nil + } + } + err := fmt.Errorf("could not find the bootstrap skill (looked for %s)", + strings.Join(bootstrapSkillNames, ", ")) + output.PrintError(output.CodeGeneralError, err.Error(), map[string]any{ + "available_skills": available, + }) + return nil, err +} + +// resolveInstallTargets validates --target and returns the matching layouts. +func resolveInstallTargets(cmd *cobra.Command) ([]installTarget, error) { + raw, _ := cmd.Flags().GetString("target") + requested := strings.Split(raw, ",") + out := make([]installTarget, 0, len(requested)) + seen := make(map[string]bool, len(requested)) + for _, name := range requested { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" || seen[name] { + continue + } + seen[name] = true + var match *installTarget + for i := range installTargets { + if installTargets[i].name == name { + match = &installTargets[i] + break + } + } + if match == nil { + valid := make([]string, len(installTargets)) + for i, t := range installTargets { + valid[i] = t.name + } + err := fmt.Errorf("unknown target %q", name) + output.PrintError(output.CodeValidationError, err.Error(), map[string]any{ + "valid_targets": valid, + }) + return nil, err + } + out = append(out, *match) + } + if len(out) == 0 { + err := fmt.Errorf("no install targets selected") + output.PrintError(output.CodeValidationError, err.Error(), nil) + return nil, err + } + return out, nil +} + +// resolveInstallScope decides between "global" and "project". An explicit +// --global/--project flag wins; otherwise it prompts on an interactive +// terminal, and falls back to "global" when input is not a TTY. +func resolveInstallScope(cmd *cobra.Command) (string, error) { + global, _ := cmd.Flags().GetBool("global") + project, _ := cmd.Flags().GetBool("project") + switch { + case global && project: + err := fmt.Errorf("--global and --project are mutually exclusive") + output.PrintError(output.CodeValidationError, err.Error(), nil) + return "", err + case global: + return "global", nil + case project: + return "project", nil + } + + in := cmd.InOrStdin() + stderr := cmd.ErrOrStderr() + if !inputIsTerminal(in) { + // Non-interactive (CI, agent, piped): default to global. + return "global", nil + } + + if writerIsTerminal(stderr) { + fmt.Fprintln(stderr, tui.Logo(stderr)) + fmt.Fprintln(stderr) + } + return promptScope(in, stderr) +} + +// promptScope asks the user where to install and returns "global" or "project". +// +// It installs its own SIGINT/SIGTERM handler for the duration of the read. +// When the CLI is launched by a parent that already ignores SIGINT (agents, +// IDEs, some job-control setups), Go inherits that disposition and Ctrl+C would +// otherwise be swallowed, leaving the blocking read — and the whole app — hung. +// Notifying here overrides the inherited disposition so Ctrl+C aborts cleanly. +func promptScope(in io.Reader, out io.Writer) (string, error) { + fmt.Fprintln(out, "Where should the skills be installed?") + fmt.Fprintln(out, " [1] Global — your home directory (~/.claude, ~/.agents), available in every project") + fmt.Fprintln(out, " [2] Project — the current directory (./.claude, ./.agents) only") + fmt.Fprint(out, "Choose [1/2] (default 1): ") + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + line, err := readLineContext(ctx, in) + if err != nil { + if ctx.Err() != nil { + // Interrupted: end the prompt line and abort without a stack of noise. + fmt.Fprintln(out) + return "", fmt.Errorf("installation cancelled") + } + return "", fmt.Errorf("failed to read choice: %w", err) + } + + switch strings.TrimSpace(line) { + case "", "1", "g", "global": + return "global", nil + case "2", "p", "project": + return "project", nil + default: + err := fmt.Errorf("invalid choice; expected 1 or 2") + output.PrintError(output.CodeValidationError, err.Error(), nil) + return "", err + } +} + +// readLineContext reads a single line from in, returning early with ctx.Err() +// if ctx is cancelled (e.g. by SIGINT) before input arrives. +// +// Note: on cancellation the reader goroutine is abandoned, still blocked on +// Scan(). That is only safe because the sole caller (the install scope prompt) +// treats cancellation as a fatal abort and the process exits immediately. Keep +// this function internal; do not reuse it on a path that continues running, or +// the leaked goroutine will accumulate. +func readLineContext(ctx context.Context, in io.Reader) (string, error) { + lines := make(chan string, 1) + errs := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(in) + if scanner.Scan() { + lines <- scanner.Text() + return + } + if err := scanner.Err(); err != nil { + errs <- err + return + } + lines <- "" // EOF with no input + }() + + select { + case <-ctx.Done(): + return "", ctx.Err() + case err := <-errs: + return "", err + case line := <-lines: + return line, nil + } +} + +// resolveInstallBase returns the directory under which target subdirs are +// created: the home dir for "global", the working directory for "project". +func resolveInstallBase(scope string) (string, error) { + if scope == "project" { + return os.Getwd() + } + return os.UserHomeDir() +} + +// inputIsTerminal reports whether the reader is an interactive terminal. +func inputIsTerminal(in io.Reader) bool { + f, ok := in.(*os.File) + if !ok { + return false + } + return isTerminalInput(f.Fd()) +} + +// writerIsTerminal reports whether the writer is an interactive terminal, so +// the decorative banner is only printed for humans. (term.IsTerminal works on +// any FD, so reusing isTerminalInput here is fine.) +func writerIsTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + return isTerminalInput(f.Fd()) +} diff --git a/cmd/skills_install_test.go b/cmd/skills_install_test.go new file mode 100644 index 0000000..2cec6a8 --- /dev/null +++ b/cmd/skills_install_test.go @@ -0,0 +1,294 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// runSkillsInstall drives `cio skills install` against a test server with a +// scratch HOME and working directory so on-disk writes are sandboxed. +func runInstallCommand(t *testing.T, srv *httptest.Server, home string, args ...string) (string, error) { + t.Helper() + + cacheDir := t.TempDir() + t.Setenv("CIO_SKILLS_CACHE_DIR", cacheDir) + t.Setenv("HOME", home) + + cmd := rootCmd + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(new(bytes.Buffer)) + cmd.SetIn(new(bytes.Buffer)) // non-TTY: scope defaults to global + t.Cleanup(func() { cmd.SetIn(nil) }) + + // rootCmd is shared across tests and pflag retains parsed values between + // Execute calls, so reset the flags this command touches to their defaults. + _ = rootCmd.PersistentFlags().Set("dry-run", "false") + for _, name := range []string{"global", "project", "force"} { + _ = skillsInstallCmd.Flags().Set(name, "false") + } + _ = skillsInstallCmd.Flags().Set("target", "claude,codex") + + fullArgs := append([]string{"skills", "install", "--api-url", srv.URL}, args...) + cmd.SetArgs(fullArgs) + err := cmd.Execute() + return buf.String(), err +} + +func TestSkillsInstallBootstrapOnly(t *testing.T) { + srv := testSkillsServer(t) + defer srv.Close() + + home := t.TempDir() + out, err := runInstallCommand(t, srv, home) + if err != nil { + t.Fatalf("install failed: %v\noutput: %s", err, out) + } + + var result struct { + Scope string `json:"scope"` + Installed []struct { + Skill string `json:"skill"` + Target string `json:"target"` + } `json:"installed"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("invalid JSON: %v\noutput: %s", err, out) + } + if result.Scope != "global" { + t.Errorf("expected scope global, got %q", result.Scope) + } + // Only the bootstrap skill (cli), once per target (claude+codex). + if len(result.Installed) != 2 { + t.Fatalf("expected 2 entries (cli x claude+codex), got %d: %+v", len(result.Installed), result.Installed) + } + for _, e := range result.Installed { + if e.Skill != "cli" { + t.Errorf("expected only the bootstrap skill, got %q", e.Skill) + } + } + + // The other skills must NOT be written locally — they are served by the + // API and read at runtime via `cio skills read `. + for _, other := range []string{"fly-api", "liquid-syntax"} { + if _, err := os.Stat(filepath.Join(home, ".claude", "skills", other)); !os.IsNotExist(err) { + t.Errorf("non-bootstrap skill %q must not be installed, got err=%v", other, err) + } + } + + // Bootstrap SKILL.md exists for both targets: server-tuned frontmatter plus + // a minimal body that just points the agent at `cio prime`. + for _, target := range []string{".claude", ".agents"} { + p := filepath.Join(home, target, "skills", "cli", "SKILL.md") + data, err := os.ReadFile(p) + if err != nil { + t.Fatalf("expected bootstrap SKILL.md at %s: %v", p, err) + } + if !strings.HasPrefix(string(data), "---\nname: \"cli\"\ndescription: \"Builder onboarding.\"\n---\n") { + t.Errorf("expected frontmatter on %s, got:\n%s", p, data) + } + if !strings.Contains(string(data), "cio prime") { + t.Errorf("expected bootstrap body to point at `cio prime` in %s, got:\n%s", p, data) + } + } + // Bootstrap sub-files are fetched at runtime, not installed. + if _, err := os.Stat(filepath.Join(home, ".claude", "skills", "cli", "onboarding.md")); !os.IsNotExist(err) { + t.Errorf("bootstrap sub-files must not be installed, got err=%v", err) + } +} + +func TestEnsureFrontmatter(t *testing.T) { + // Missing frontmatter: prepend name + description. + got := ensureFrontmatter("# Title\n\nbody", "fly-api", "Endpoint reference.") + want := "---\nname: \"fly-api\"\ndescription: \"Endpoint reference.\"\n---\n\n# Title\n\nbody" + if got != want { + t.Errorf("ensureFrontmatter mismatch:\n got: %q\nwant: %q", got, want) + } + + // Already has frontmatter: leave untouched. + withFM := "---\nname: x\ndescription: y\n---\n\n# Body" + if got := ensureFrontmatter(withFM, "ignored", "ignored"); got != withFM { + t.Errorf("expected content with frontmatter untouched, got: %q", got) + } + + // Multi-line / quote-bearing descriptions collapse to a safe scalar. + got = ensureFrontmatter("body", "s", "line one\nline \"two\"") + if !strings.HasPrefix(got, "---\nname: \"s\"\ndescription: \"line one line \\\"two\\\"\"\n---\n") { + t.Errorf("unexpected scalar handling:\n%s", got) + } +} + +func TestSkillsInstallDryRun(t *testing.T) { + srv := testSkillsServer(t) + defer srv.Close() + + home := t.TempDir() + out, err := runInstallCommand(t, srv, home, "--dry-run") + if err != nil { + t.Fatalf("dry-run failed: %v", err) + } + + var result struct { + DryRun bool `json:"dry_run"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if !result.DryRun { + t.Error("expected dry_run true") + } + if _, err := os.Stat(filepath.Join(home, ".claude")); !os.IsNotExist(err) { + t.Errorf("dry-run must not write to disk, got err=%v", err) + } +} + +func TestSkillsInstallTargetSelection(t *testing.T) { + srv := testSkillsServer(t) + defer srv.Close() + + home := t.TempDir() + if _, err := runInstallCommand(t, srv, home, "--target", "codex"); err != nil { + t.Fatalf("install failed: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "cli", "SKILL.md")); err != nil { + t.Errorf("expected codex install: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".claude")); !os.IsNotExist(err) { + t.Errorf("--target codex must not write Claude Code dir, got err=%v", err) + } +} + +func TestSkillsInstallUnknownTarget(t *testing.T) { + srv := testSkillsServer(t) + defer srv.Close() + + home := t.TempDir() + if _, err := runInstallCommand(t, srv, home, "--target", "bogus"); err == nil { + t.Fatal("expected error for unknown target") + } +} + +func TestSkillsInstallProject(t *testing.T) { + srv := testSkillsServer(t) + defer srv.Close() + + home := t.TempDir() + proj := t.TempDir() + t.Chdir(proj) + + if _, err := runInstallCommand(t, srv, home, "--project", "--target", "claude"); err != nil { + t.Fatalf("install failed: %v", err) + } + if _, err := os.Stat(filepath.Join(proj, ".claude", "skills", "cli", "SKILL.md")); err != nil { + t.Errorf("expected project install under cwd: %v", err) + } +} + +func TestSkillsInstallNoForceSkipsExisting(t *testing.T) { + srv := testSkillsServer(t) + defer srv.Close() + + home := t.TempDir() + skillDir := filepath.Join(home, ".claude", "skills", "cli") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + existing := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(existing, []byte("KEEP ME"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := runInstallCommand(t, srv, home, "--target", "claude"); err != nil { + t.Fatalf("install failed: %v", err) + } + data, err := os.ReadFile(existing) + if err != nil { + t.Fatal(err) + } + if string(data) != "KEEP ME" { + t.Errorf("expected existing file untouched without --force, got: %s", data) + } + + // With --force it gets overwritten. + if _, err := runInstallCommand(t, srv, home, "--target", "claude", "--force"); err != nil { + t.Fatalf("install --force failed: %v", err) + } + data, err = os.ReadFile(existing) + if err != nil { + t.Fatal(err) + } + if string(data) == "KEEP ME" { + t.Error("expected --force to overwrite existing file") + } +} + +func TestSafeRelPath(t *testing.T) { + for _, name := range []string{"SKILL.md", "recipes/liquid.md", "a/b/c.md"} { + if _, err := safeRelPath(name); err != nil { + t.Errorf("safeRelPath(%q) unexpected error: %v", name, err) + } + } + for _, name := range []string{"", "..", "../escape", "../../etc/passwd", "/abs/path", "a/../../b"} { + if _, err := safeRelPath(name); err == nil { + t.Errorf("safeRelPath(%q) expected error", name) + } + } +} + +func TestPromptScope(t *testing.T) { + cases := map[string]string{ + "\n": "global", + "1\n": "global", + "global\n": "global", + "2\n": "project", + "project\n": "project", + " 2 \n": "project", + } + for in, want := range cases { + got, err := promptScope(strings.NewReader(in), new(bytes.Buffer)) + if err != nil { + t.Errorf("promptScope(%q) error: %v", in, err) + continue + } + if got != want { + t.Errorf("promptScope(%q) = %q, want %q", in, got, want) + } + } + + if _, err := promptScope(strings.NewReader("nonsense\n"), new(bytes.Buffer)); err == nil { + t.Error("expected error for invalid choice") + } +} + +func TestReadLineContextCancel(t *testing.T) { + // A reader that never yields a line, simulating a stuck terminal read. + pr, pw := io.Pipe() + t.Cleanup(func() { _ = pw.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled, as if SIGINT fired + + done := make(chan struct{}) + var err error + go func() { + _, err = readLineContext(ctx, pr) + close(done) + }() + + select { + case <-done: + if err == nil { + t.Fatal("expected cancellation error") + } + case <-time.After(2 * time.Second): + t.Fatal("readLineContext did not return on cancellation (hang)") + } +} diff --git a/cmd/skills_test.go b/cmd/skills_test.go index 44850ce..18f1233 100644 --- a/cmd/skills_test.go +++ b/cmd/skills_test.go @@ -5,15 +5,25 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + "github.com/customerio/cli/internal/client" "github.com/customerio/cli/internal/skills" ) -func testSkillsServer(t *testing.T) *httptest.Server { - t.Helper() +// skillsFixture returns the bundle the test server serves. Plan scoping is +// resolved server-side (out-of-scope files are omitted before the bundle is +// sent), so the fixture is the already-scoped response the CLI receives. +// accountPlan, when non-empty, is appended to the prompt as the real server +// does for authenticated callers. +func skillsFixture(accountPlan string) *skills.SkillsResponse { + prompt := "## Skills\n\nRouting rules here.\n\n\n" + if accountPlan != "" { + prompt += "\n\nAccount plan: " + accountPlan + "." + } resp := &skills.SkillsResponse{ - Prompt: "## Skills\n\nRouting rules here.\n\n\n", + Prompt: prompt, Skills: []skills.Skill{ { Path: "fly-api", @@ -21,7 +31,9 @@ func testSkillsServer(t *testing.T) *httptest.Server { Description: "Complete endpoint reference.", Content: "# Fly API\n\nMain content.", Files: map[string]string{ - "campaigns.md": "# Campaigns\n\nCampaign details.", + "campaigns.md": "# Campaigns\n\nCampaign details.", + "broadcasts.md": "# Broadcasts\n\nBroadcast details.", + "segments.md": "# Segments\n\nSegment details.", }, }, { @@ -41,10 +53,26 @@ func testSkillsServer(t *testing.T) *httptest.Server { Files: map[string]string{ "onboarding.md": "---\nname: onboarding\ndescription: Builder onboarding entry point.\n---\n\n# Onboarding\n", "auth.md": "---\nname: auth\ndescription: cio CLI authentication reference.\n---\n\n# Auth\n", + "anonymous.md": "---\nname: anonymous\ndescription: Anonymous broadcasts.\n---\n\n# Anonymous\n", }, }, }, } + return resp +} + +// testSkillsServer serves the full, unscoped bundle (an unauthenticated fetch). +func testSkillsServer(t *testing.T) *httptest.Server { + t.Helper() + return skillsServerWithResponse(t, skillsFixture(""), nil) +} + +// skillsServerWithResponse serves the given bundle from /v1/agent/skills and +// also answers the OAuth token exchange, so authenticated fetches work. When +// gotAuth is non-nil, the Authorization header sent to the skills endpoint is +// recorded into it. +func skillsServerWithResponse(t *testing.T, resp *skills.SkillsResponse, gotAuth *string) *httptest.Server { + t.Helper() data, err := json.Marshal(resp) if err != nil { t.Fatal(err) @@ -52,23 +80,48 @@ func testSkillsServer(t *testing.T) *httptest.Server { etag := skills.ComputeETag(data) return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/agent/skills" { - http.NotFound(w, r) - return - } - if match := r.Header.Get("If-None-Match"); match == etag { + switch r.URL.Path { + case "/v1/service_accounts/oauth/token": + _, _ = w.Write([]byte(`{"access_token":"jwt-test-session","token_type":"Bearer","expires_in":3600}`)) + case "/v1/agent/skills": + if gotAuth != nil { + *gotAuth = r.Header.Get("Authorization") + } + if match := r.Header.Get("If-None-Match"); match == etag { + w.Header().Set("ETag", etag) + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("Content-Type", "application/json") w.Header().Set("ETag", etag) - w.WriteHeader(http.StatusNotModified) - return + _, _ = w.Write(data) + default: + http.NotFound(w, r) } - w.Header().Set("Content-Type", "application/json") - w.Header().Set("ETag", etag) - w.Write(data) })) } +// isolateSkillsHome points HOME at a temp dir (so plan resolution never sees +// or touches the developer's real ~/.cio/config.json) and clears CIO_TOKEN. +// Returns the temp home dir so tests can seed credentials into it. +func isolateSkillsHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CIO_TOKEN", "") + return home +} + func runSkillsCommand(t *testing.T, srv *httptest.Server, args ...string) (string, error) { t.Helper() + isolateSkillsHome(t) + return execSkillsCommand(t, srv, args...) +} + +// execSkillsCommand runs `cio skills` against srv without touching HOME, so +// tests that seed ~/.cio/config.json keep their credentials. +func execSkillsCommand(t *testing.T, srv *httptest.Server, args ...string) (string, error) { + t.Helper() dir := t.TempDir() cmd := rootCmd @@ -76,7 +129,9 @@ func runSkillsCommand(t *testing.T, srv *httptest.Server, args ...string) (strin cmd.SetOut(buf) cmd.SetErr(new(bytes.Buffer)) - fullArgs := append([]string{"skills", "--api-url", srv.URL}, args...) + // Reset --token explicitly: rootCmd is shared package state, so a value + // set by an earlier test would otherwise leak into plan resolution. + fullArgs := append([]string{"skills", "--api-url", srv.URL, "--token", ""}, args...) // Set env to use test cache dir. t.Setenv("CIO_SKILLS_CACHE_DIR", dir) cmd.SetArgs(fullArgs) @@ -142,11 +197,12 @@ func TestSkillsListIncludesFileDescriptions(t *testing.T) { t.Fatal("expected cli skill in list") } // Files come back sorted, each carrying its frontmatter description. - if len(cli.Files) != 2 || cli.Files[0].Name != "auth.md" || cli.Files[1].Name != "onboarding.md" { - t.Fatalf("expected files sorted [auth.md onboarding.md], got %+v", cli.Files) + // With no stored plan info, nothing is filtered. + if len(cli.Files) != 3 || cli.Files[0].Name != "anonymous.md" || cli.Files[1].Name != "auth.md" || cli.Files[2].Name != "onboarding.md" { + t.Fatalf("expected files sorted [anonymous.md auth.md onboarding.md], got %+v", cli.Files) } - if cli.Files[1].Description != "Builder onboarding entry point." { - t.Errorf("expected onboarding.md description from frontmatter, got %q", cli.Files[1].Description) + if cli.Files[2].Description != "Builder onboarding entry point." { + t.Errorf("expected onboarding.md description from frontmatter, got %q", cli.Files[2].Description) } } @@ -235,6 +291,31 @@ func TestSkillsPrompt(t *testing.T) { if result["prompt"] == "" { t.Error("expected non-empty prompt") } + // No stored plan info — the prompt must not state a plan. + if strings.Contains(result["prompt"], "Account plan:") { + t.Errorf("expected no account plan line, got prompt: %q", result["prompt"]) + } +} + +func TestSkillsPromptIncludesAccountPlan(t *testing.T) { + // The plan line is resolved and appended server-side; the CLI passes the + // prompt through verbatim. + srv := skillsServerWithResponse(t, skillsFixture("premium"), nil) + defer srv.Close() + + out, err := runSkillsCommand(t, srv, "prompt") + if err != nil { + t.Fatal(err) + } + + var result map[string]string + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("invalid JSON: %v\noutput: %s", err, out) + } + + if !strings.Contains(result["prompt"], "Account plan: premium.") { + t.Errorf("expected prompt to state the account plan, got: %q", result["prompt"]) + } } func TestSkillsReadUnknown(t *testing.T) { @@ -246,3 +327,43 @@ func TestSkillsReadUnknown(t *testing.T) { t.Fatal("expected error for unknown skill") } } + +// TestSkillsSendsBearerTokenWhenAuthenticated verifies the CLI exchanges its +// stored credential and sends the resulting Bearer token, so the server can +// scope the bundle to the account. +func TestSkillsSendsBearerTokenWhenAuthenticated(t *testing.T) { + var gotAuth string + srv := skillsServerWithResponse(t, skillsFixture(""), &gotAuth) + defer srv.Close() + + isolateSkillsHome(t) + if err := client.WriteCredentials(&client.Credentials{ + ServiceAccountToken: "sa_live_authtest", + AccountID: "1", + Region: "us", + }); err != nil { + t.Fatal(err) + } + + if _, err := execSkillsCommand(t, srv); err != nil { + t.Fatal(err) + } + if gotAuth != "Bearer jwt-test-session" { + t.Errorf("expected Bearer token on the skills request, got %q", gotAuth) + } +} + +// TestSkillsUnauthenticatedSendsNoToken verifies that without credentials the +// CLI fetches the bundle unauthenticated (no Authorization header). +func TestSkillsUnauthenticatedSendsNoToken(t *testing.T) { + var gotAuth string + srv := skillsServerWithResponse(t, skillsFixture(""), &gotAuth) + defer srv.Close() + + if _, err := runSkillsCommand(t, srv); err != nil { + t.Fatal(err) + } + if gotAuth != "" { + t.Errorf("expected no Authorization header, got %q", gotAuth) + } +} diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go new file mode 100644 index 0000000..b9bba6a --- /dev/null +++ b/internal/clipboard/clipboard.go @@ -0,0 +1,63 @@ +// Package clipboard reads the system clipboard by shelling out to the +// platform's clipboard tool, keeping the CLI free of cgo and extra +// dependencies. +package clipboard + +import ( + "context" + "errors" + "fmt" + "os/exec" + "runtime" +) + +// ErrNoTool means no clipboard tool was found on PATH. Typical in remote +// shells (SSH, containers), where the user's local clipboard isn't visible +// anyway. +var ErrNoTool = errors.New("no clipboard tool found on PATH") + +type tool struct { + name string + args []string +} + +// candidates returns the clipboard read commands for the given OS, in +// preference order. +func candidates(goos string) []tool { + switch goos { + case "darwin": + return []tool{{name: "pbpaste"}} + case "windows": + return []tool{{name: "powershell", args: []string{"-NoProfile", "-Command", "Get-Clipboard"}}} + default: // linux and other unixes + return []tool{ + {name: "wl-paste", args: []string{"--no-newline"}}, + {name: "xclip", args: []string{"-o", "-selection", "clipboard"}}, + {name: "xsel", args: []string{"-b"}}, + } + } +} + +// Test seams. +var ( + lookPath = exec.LookPath + runCommand = func(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).Output() + } +) + +// Read returns the clipboard's text content using the first available +// clipboard tool. Returns ErrNoTool when none is installed. +func Read(ctx context.Context) (string, error) { + for _, t := range candidates(runtime.GOOS) { + if _, err := lookPath(t.name); err != nil { + continue + } + out, err := runCommand(ctx, t.name, t.args...) + if err != nil { + return "", fmt.Errorf("%s failed: %w", t.name, err) + } + return string(out), nil + } + return "", ErrNoTool +} diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go new file mode 100644 index 0000000..ddb974d --- /dev/null +++ b/internal/clipboard/clipboard_test.go @@ -0,0 +1,77 @@ +package clipboard + +import ( + "context" + "errors" + "testing" +) + +func TestCandidatesPerOS(t *testing.T) { + tests := []struct { + goos string + first string + count int + }{ + {"darwin", "pbpaste", 1}, + {"windows", "powershell", 1}, + {"linux", "wl-paste", 3}, + {"freebsd", "wl-paste", 3}, + } + for _, tt := range tests { + got := candidates(tt.goos) + if len(got) != tt.count { + t.Errorf("%s: expected %d candidates, got %d", tt.goos, tt.count, len(got)) + } + if got[0].name != tt.first { + t.Errorf("%s: expected first candidate %q, got %q", tt.goos, tt.first, got[0].name) + } + } +} + +func TestReadNoToolOnPath(t *testing.T) { + origLookPath := lookPath + defer func() { lookPath = origLookPath }() + lookPath = func(string) (string, error) { return "", errors.New("not found") } + + _, err := Read(context.Background()) + if !errors.Is(err, ErrNoTool) { + t.Fatalf("expected ErrNoTool, got %v", err) + } +} + +func TestReadUsesFirstAvailableTool(t *testing.T) { + origLookPath, origRun := lookPath, runCommand + defer func() { lookPath, runCommand = origLookPath, origRun }() + + lookPath = func(name string) (string, error) { return "/usr/bin/" + name, nil } + var ran string + runCommand = func(_ context.Context, name string, _ ...string) ([]byte, error) { + ran = name + return []byte("sa_live_test\n"), nil + } + + got, err := Read(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sa_live_test\n" { + t.Errorf("expected raw clipboard content, got %q", got) + } + if ran == "" { + t.Error("expected a clipboard tool to run") + } +} + +func TestReadToolFailure(t *testing.T) { + origLookPath, origRun := lookPath, runCommand + defer func() { lookPath, runCommand = origLookPath, origRun }() + + lookPath = func(name string) (string, error) { return "/usr/bin/" + name, nil } + runCommand = func(context.Context, string, ...string) ([]byte, error) { + return nil, errors.New("display not available") + } + + if _, err := Read(context.Background()); err == nil { + t.Fatal("expected error when the tool fails") + } +} diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 6f08819..393e755 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -39,6 +39,9 @@ type Skill struct { } // SortedFiles returns the skill's sub-file names in stable alphabetical order. +// Plan scoping is resolved server-side: out-of-scope files are already omitted +// from Files (GET /v1/agent/skills), so the CLI lists and reads whatever it +// receives. func (s Skill) SortedFiles() []string { names := make([]string, 0, len(s.Files)) for name := range s.Files { @@ -126,6 +129,15 @@ type LoadOptions struct { TTL time.Duration // HTTPClient overrides the HTTP client (for testing). HTTPClient *http.Client + // AccessToken, when set, is sent as a Bearer token so the server scopes the + // bundle to the account's plan (out-of-scope files flagged). Empty fetches + // the full, unscoped bundle. + AccessToken string + // CacheScope identifies the account the cache entry belongs to (e.g. the + // account ID), so a scoped bundle is never reused across accounts. Empty for + // an unauthenticated fetch. The token itself is unsuitable as a key — the + // access token rotates, the scope is stable per account. + CacheScope string } type skillsMeta struct { @@ -137,6 +149,11 @@ type skillsMeta struct { // version), so a different one — e.g. after a CLI upgrade — must not reuse // this cache entry. UserAgent string `json:"user_agent,omitempty"` + // Scope is the account scope the cached bundle was fetched for (see + // LoadOptions.CacheScope). The response is plan-scoped to the caller, so a + // bundle fetched for one account — or anonymously ("") — must not be reused + // for another. + Scope string `json:"scope,omitempty"` } func (o *LoadOptions) resolveBaseURL() string { @@ -211,7 +228,7 @@ func EnsureSkills(ctx context.Context, opts LoadOptions) (*SkillsResponse, error ttl := opts.resolveTTL() httpClient := opts.resolveHTTPClient() - data, err := ensureSkillsData(ctx, httpClient, cacheDir, baseURL, useragent.Get(), meta, ttl, opts.ForceRefresh) + data, err := ensureSkillsData(ctx, httpClient, cacheDir, baseURL, useragent.Get(), opts.AccessToken, opts.CacheScope, meta, ttl, opts.ForceRefresh) if err != nil { return nil, err } @@ -226,17 +243,18 @@ func EnsureSkills(ctx context.Context, opts LoadOptions) (*SkillsResponse, error func ensureSkillsData( ctx context.Context, httpClient *http.Client, - cacheDir, baseURL, userAgent string, + cacheDir, baseURL, userAgent, accessToken, scope string, meta *skillsMeta, ttl time.Duration, forceRefresh bool, ) ([]byte, error) { cachedPath := filepath.Join(cacheDir, skillsCacheFile) - // Cache is fresh only when it was fetched with this User-Agent — the - // response varies by it, so a UA change (e.g. a CLI upgrade) is a miss. - sameUA := meta.UserAgent == userAgent - if !forceRefresh && meta.ETag != "" && sameUA && time.Since(meta.FetchedAt) < ttl { + // Cache is fresh only when it was fetched with this User-Agent and account + // scope — the response varies by both, so a UA change (e.g. a CLI upgrade) + // or an account switch is a miss. + sameVariant := meta.UserAgent == userAgent && meta.Scope == scope + if !forceRefresh && meta.ETag != "" && sameVariant && time.Since(meta.FetchedAt) < ttl { data, err := os.ReadFile(cachedPath) if err == nil { return data, nil @@ -245,21 +263,24 @@ func ensureSkillsData( } // Conditional request, but only revalidate against the cached ETag when the - // cached entry is for this UA; a different UA must fetch its own variant - // rather than risk a 304 onto the wrong cached one. + // cached entry is for this UA and scope; a different variant must fetch its + // own rather than risk a 304 onto the wrong cached one. conditionalETag := meta.ETag - if !sameUA { + if !sameVariant { conditionalETag = "" } url := baseURL + skillsEndpointPath - newData, newETag, dlErr := downloadSkills(ctx, httpClient, url, userAgent, conditionalETag) + newData, newETag, dlErr := downloadSkills(ctx, httpClient, url, userAgent, accessToken, conditionalETag) if dlErr != nil { - // Try stale cache on download failure. - data, readErr := os.ReadFile(cachedPath) - if readErr == nil { - fmt.Fprintf(os.Stderr, "warning: using stale cached skills (download failed: %v)\n", dlErr) - return data, nil + // Try stale cache on download failure, but only when it's this variant — + // serving another account's scoped bundle would be wrong. + if sameVariant { + data, readErr := os.ReadFile(cachedPath) + if readErr == nil { + fmt.Fprintf(os.Stderr, "warning: using stale cached skills (download failed: %v)\n", dlErr) + return data, nil + } } return nil, dlErr } @@ -268,6 +289,7 @@ func ensureSkillsData( // 304 Not Modified — update timestamp, read from cache. meta.FetchedAt = time.Now().UTC() meta.UserAgent = userAgent + meta.Scope = scope if err := writeMeta(cacheDir, meta); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to write skills cache metadata: %v\n", err) } @@ -287,6 +309,7 @@ func ensureSkillsData( meta.FetchedAt = time.Now().UTC() meta.Size = int64(len(newData)) meta.UserAgent = userAgent + meta.Scope = scope if err := writeMeta(cacheDir, meta); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to write skills cache metadata: %v\n", err) } @@ -294,13 +317,18 @@ func ensureSkillsData( return newData, nil } -func downloadSkills(ctx context.Context, httpClient *http.Client, url, userAgent, etag string) (data []byte, newETag string, err error) { +func downloadSkills(ctx context.Context, httpClient *http.Client, url, userAgent, accessToken, etag string) (data []byte, newETag string, err error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, "", err } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", userAgent) + // Authenticated fetches get a bundle scoped to the account's plan; the + // endpoint also serves unauthenticated callers (the full, unscoped bundle). + if accessToken != "" { + req.Header.Set("Authorization", "Bearer "+accessToken) + } if etag != "" { req.Header.Set("If-None-Match", etag) } diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go index ba81b1c..3d91687 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -389,3 +389,57 @@ func TestEnsureSkills_UserAgentChangeInvalidatesCache(t *testing.T) { t.Fatalf("expected cache hit for same UA, got %d requests", requestCount) } } + +func TestEnsureSkills_AccountScopeChangeInvalidatesCache(t *testing.T) { + requestCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + // Vary the body by the caller, like the real (plan-scoped) server. + body, _ := json.Marshal(&SkillsResponse{Prompt: "auth=" + r.Header.Get("Authorization")}) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", ComputeETag(body)) + _, _ = w.Write(body) + })) + defer srv.Close() + + base := LoadOptions{ + BaseURL: srv.URL, + CacheDir: t.TempDir(), + TTL: time.Hour, // long: only a scope change should force a refetch + } + + // Account A. + optsA := base + optsA.AccessToken = "jwt-a" + optsA.CacheScope = "acct-a" + got, err := EnsureSkills(context.Background(), optsA) + if err != nil { + t.Fatal(err) + } + if got.Prompt != "auth=Bearer jwt-a" || requestCount != 1 { + t.Fatalf("A: prompt=%q requests=%d", got.Prompt, requestCount) + } + + // Account B within TTL must refetch — a scoped bundle is not shared. + optsB := base + optsB.AccessToken = "jwt-b" + optsB.CacheScope = "acct-b" + got, err = EnsureSkills(context.Background(), optsB) + if err != nil { + t.Fatal(err) + } + if got.Prompt != "auth=Bearer jwt-b" { + t.Fatalf("B: expected refetched variant, got prompt=%q", got.Prompt) + } + if requestCount != 2 { + t.Fatalf("B: expected a refetch on account change, got %d requests", requestCount) + } + + // Account A again within TTL: same scope, cache hit, no new request. + if _, err = EnsureSkills(context.Background(), optsB); err != nil { + t.Fatal(err) + } + if requestCount != 2 { + t.Fatalf("expected cache hit for same scope, got %d requests", requestCount) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index e168c91..c1c17dd 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2,27 +2,47 @@ package tui import ( "io" - "os" "strings" "github.com/charmbracelet/lipgloss" ) -const logoText = ` ██████╗ ██╗ ██████╗ -██╔════╝ ██║ ██╔═══██╗ -██║ ██║ ██║ ██║ -██║ ██║ ██║ ██║ -╚██████╗ ██║ ╚██████╔╝ - ╚═════╝ ╚═╝ ╚═════╝` +const logoText = `█████▄▄ ▄▄▄▄▄▄▄ +████████▄ ████████▄ + ▀█████████▄ ██████████▄ + ▀█████████▄ ████████████▄ + ▀████████▄ █████████████ + ▄████████▀ █████████████ + ▄█████████▀ ████████████▀ + ▄█████████▀ ██████████▀ +████████▀ ████████▀ +█████▀▀ ▀▀▀▀▀▀▀` type command struct { name string desc string } -// RenderHelp writes the branded help screen to w. -func RenderHelp(w io.Writer) { - r := lipgloss.NewRenderer(os.Stdout) +// Command is a name/description pair for the "All commands" listing. Callers +// pass the live set of registered commands so the help screen never drifts out +// of sync with what the CLI actually supports. +type Command struct { + Name string + Desc string +} + +// Logo returns the cio wordmark rendered in the brand green, suitable for +// printing as a banner above interactive flows. Pass the writer the logo will +// actually be printed to so color capability is detected for that destination. +func Logo(w io.Writer) string { + r := lipgloss.NewRenderer(w) + return r.NewStyle().Foreground(lipgloss.Color("#7FE07F")).Render(logoText) +} + +// RenderHelp writes the branded help screen to w. all is the complete set of +// top-level commands, rendered under "All commands". +func RenderHelp(w io.Writer, all []Command) { + r := lipgloss.NewRenderer(w) logo := r.NewStyle().Foreground(lipgloss.Color("#7FE07F")) tag := r.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) @@ -56,5 +76,20 @@ func RenderHelp(w io.Writer) { b.WriteString(" " + cmd.Render(c.name) + " " + desc.Render(c.desc) + "\n") } + if len(all) > 0 { + width := 0 + for _, c := range all { + if len(c.Name) > width { + width = len(c.Name) + } + } + b.WriteString("\n") + b.WriteString(header.Render("All commands:") + "\n") + for _, c := range all { + pad := strings.Repeat(" ", width-len(c.Name)+2) + b.WriteString(" " + cmd.Render(c.Name) + pad + desc.Render(c.Desc) + "\n") + } + } + io.WriteString(w, b.String()) } diff --git a/skills/SKILL-auth.md b/skills/SKILL-auth.md deleted file mode 100644 index 99274ab..0000000 --- a/skills/SKILL-auth.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -skill: auth -domain: Authentication, token management, OAuth exchange, credential storage ---- - -# Auth - -The Customer.io CLI (`cio`) authenticates using service account tokens exchanged for short-lived JWTs via OAuth 2.0 client credentials grant against the UI API at `fly.customer.io`. Production tokens use `sa_live_...`; sandbox Builder accounts use `sa_sandbox_...` until go-live. - -## Invariants - -- Tokens are stored in `~/.cio/config.json` with `0600` permissions. -- Token resolution: `--token` flag → `CIO_TOKEN` env var → config file. -- `sa_live_` and `sa_sandbox_` tokens CANNOT be used directly as Bearer tokens. They must be exchanged. -- The exchange endpoint is `POST /v1/service_accounts/oauth/token` (unauthenticated, rate-limited by IP). -- The exchange returns a short-lived JWT (`access_token`) with an `expires_in` field. -- The CLI caches the JWT and re-exchanges automatically when it expires (with 60s buffer). -- Two data centers: US (`https://us.fly.customer.io`) and EU (`https://eu.fly.customer.io`). -- Region is stored in config and determines the base URL for all requests. -- `auth login` and `auth logout` do NOT require an existing valid token. -- `auth status` exchanges the token to verify it works. -- `auth token` prints the raw service account token to stdout (not the JWT). -- `auth signup start` and `auth signup verify` run unauthenticated; `--token` / `CIO_TOKEN` are ignored. -- `auth signup verify` persists the returned bootstrap token + `account_id` to `~/.cio/config.json` on success — no separate `auth login` needed afterward. - -## OAuth Exchange Details - -``` -POST /v1/service_accounts/oauth/token -Content-Type: application/x-www-form-urlencoded - -grant_type=client_credentials&client_secret=sa_live_... - -Response: -{"access_token":"","token_type":"Bearer","expires_in":3600} -``` - -Alternatively, credentials can be sent via HTTP Basic auth (RFC 6749 §2.3.1): -``` -Authorization: Basic base64(:) -``` - -The `client_id` is optional. If provided, it must match the service account ID. -For sandbox Builder accounts, use the returned `sa_sandbox_...` token in the -same `client_secret` position. - -## Common Workflows - -### First-time setup - -```bash -# Interactive — prints the browser login URL, then prompts for the minted token -cio auth login - -# Verify it works -cio auth status -``` - -### CI / automation setup - -```bash -# Read token from stdin (no TTY needed; login still auto-discovers region) -echo "$CIO_TOKEN" | cio auth login --with-token - -# Or just use env vars directly — in that case also provide region -CIO_TOKEN=sa_live_xxx CIO_REGION=eu cio auth status -``` - -### Check what's active - -```bash -# Shows source, masked token, region, and verifies against API -cio auth status - -# Just the raw sa_live_ token -cio auth token -``` - -### Switch regions - -```bash -# Point direct token-based API calls at EU -CIO_TOKEN=sa_live_xxx CIO_REGION=eu cio api /v1/environments/{environment_id}/campaigns --params '{"environment_id":"123"}' -``` - -### Remove credentials - -```bash -cio auth logout -``` - -### Provision a brand-new account (agentic signup) - -A 2-step unauthenticated flow that stands up a new Customer.io account and -returns an Admin-scoped bootstrap token. Non-sandbox signup returns `sa_live_...`; -sandbox Builder signup returns `sa_sandbox_...`. Use this when the agent has -no existing credentials. Both subcommands honor `--api-url` (defaults to US) -and `--dry-run`. - -```bash -# Step 1 — email a 6-digit verification code -cio auth signup start --json '{"email":"agent+demo@example.com"}' - -# Step 2 — verify the code and create the account -cio auth signup verify --json '{ - "email": "agent+demo@example.com", - "code": "123456", - "company_name": "Acme", - "first_name": "Ada", - "last_name": "Lovelace", - "data_center": "us" -}' -``` - -On success, `verify` writes the returned bootstrap token and -`account_id` to `~/.cio/config.json`, so the next `cio api ...` call is -already authenticated. The full response (including `token`) is still -printed once to stdout; the server will not return it again. - -Target a different data center with `--api-url`, e.g. -`--api-url https://eu.fly.customer.io`. - -## Troubleshooting - -### "sa_live_ credentials cannot be used directly" - -This means something is trying to use the raw token as a Bearer header. The CLI handles the exchange automatically — this error should not appear in normal usage. If it does, the OAuth exchange is being bypassed. - -### "token exchange failed" - -```bash -# Check region is correct -cio auth status - -# Override the API region for direct token-based usage -CIO_TOKEN=sa_live_xxx CIO_REGION=eu cio auth status -``` - -### JWT expired mid-session - -The CLI caches JWTs and auto-refreshes 60 seconds before expiry. On unexpected 401s (clock skew, race conditions), it automatically clears the token and retries once with a fresh JWT. Manual intervention should not be needed. - -If problems persist: - -```bash -# Force re-exchange by logging in again -cio auth login -``` - -### Wrong data center - -If your account is in EU but you're hitting US (or vice versa), API calls will fail. Check and fix: - -```bash -cio auth status # shows region -CIO_TOKEN=sa_live_xxx CIO_REGION=eu cio auth status # override region for direct token usage -```