From 07dd7e66ba6fe6c2d9d684ad7b881c03a593021a Mon Sep 17 00:00:00 2001 From: "Customer.io Open Source Bot" Date: Tue, 5 May 2026 16:14:35 +0100 Subject: [PATCH] Add Customer.io CLI source CioCliPublicExport-RevId: 4c4f3c8f9ea0f97b7ec05af2ededa1d4b57d00b0 --- cmd/api.go | 10 ++- cmd/api_test.go | 184 +++++++++++++++++++++++++++++++++++++-- cmd/prime_context.md | 2 +- internal/validate/ids.go | 39 ++++++++- skills/cio/SKILL.md | 44 ++++++---- skills/cio/billing.md | 106 ++++++++++++++++++++++ skills/cio/onboarding.md | 32 +++---- 7 files changed, 371 insertions(+), 46 deletions(-) create mode 100644 skills/cio/billing.md diff --git a/cmd/api.go b/cmd/api.go index 0c61a07..952584d 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -54,6 +54,11 @@ func runAPI(cmd *cobra.Command, args []string) error { if !strings.HasPrefix(pathTemplate, "/") { pathTemplate = "/" + pathTemplate } + if strings.ContainsAny(pathTemplate, "?#") { + err := fmt.Errorf("path must not contain query or fragment characters; pass query values via --params") + output.PrintError(output.CodeValidationError, err.Error(), nil) + return err + } // Determine HTTP method. methodFlag, _ := cmd.Flags().GetString("method") @@ -173,7 +178,8 @@ func extractPathParamNames(pathTemplate string) map[string]bool { // Path params are those matching {placeholder} in the path template. // Input is validated through validate.ValidateParams (keys must match // [a-zA-Z0-9_]+, values must not contain control characters, see -// MaxParamValueLength). Path params additionally must be numeric IDs. +// MaxParamValueLength). Path params are validated as safe URL path segments; +// the API remains the source of truth for endpoint-specific ID semantics. func parseAPIParams(pathTemplate, paramsJSON string) (pathParams, queryParams map[string]string, err error) { pathParams = make(map[string]string) queryParams = make(map[string]string) @@ -190,7 +196,7 @@ func parseAPIParams(pathTemplate, paramsJSON string) (pathParams, queryParams ma pathParamNames := extractPathParamNames(pathTemplate) for k, v := range params { if pathParamNames[k] { - if err := validate.ValidateResourceID(v); err != nil { + if err := validate.ValidatePathSegmentID(v); err != nil { return nil, nil, fmt.Errorf("path parameter %q: %w", k, err) } pathParams[k] = v diff --git a/cmd/api_test.go b/cmd/api_test.go index 6270bb3..e2a5b1a 100644 --- a/cmd/api_test.go +++ b/cmd/api_test.go @@ -39,9 +39,10 @@ func apiServer(t *testing.T) *httptest.Server { // Echo back what was received for verification response := map[string]any{ - "method": r.Method, - "path": r.URL.Path, - "query": r.URL.Query(), + "method": r.Method, + "path": r.URL.Path, + "query": r.URL.Query(), + "request_uri": r.RequestURI, } data, _ := json.Marshal(response) _, _ = w.Write(data) @@ -136,15 +137,182 @@ func TestAPI_MissingPathParam(t *testing.T) { } } -func TestAPI_InvalidPathParam(t *testing.T) { +func TestAPI_PathParamsAllowStringSegments(t *testing.T) { server, cleanup := setupAPITest(t) defer cleanup() - _, _, err := executeCommand("api", "/v1/environments/{environment_id}/campaigns", + stdout, _, err := executeCommand("api", "/v1/environments/{environment_id}/test_users/{test_user_id}", "--api-url", server.URL, - "--params", `{"environment_id": "abc"}`) - if err == nil { - t.Fatal("expected error for non-numeric ID") + "--params", `{"environment_id": "217838", "test_user_id": "profile_abc-123"}`) + 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: %v\nstdout: %s", err, stdout) + } + if result["path"] != "/v1/environments/217838/test_users/profile_abc-123" { + t.Errorf("expected resolved string path segment, got %v", result["path"]) + } +} + +func TestAPI_CustomerIDAllowsCIOID(t *testing.T) { + server, cleanup := setupAPITest(t) + defer cleanup() + + params := `{"environment_id": "217838", "customer_id": "eea50d000102"}` + wantPath := "/v1/environments/217838/customers/eea50d000102" + + tests := []struct { + name string + args []string + wantKey string + wantValue string + }{ + { + name: "get", + args: []string{"api", "/v1/environments/{environment_id}/customers/{customer_id}", + "--api-url", server.URL, + "--params", params}, + wantKey: "path", + wantValue: wantPath, + }, + { + name: "put", + args: []string{"api", "/v1/environments/{environment_id}/customers/{customer_id}", + "--api-url", server.URL, + "--params", params, + "-X", "PUT", + "--json", `{"customer":{"email":"test@example.com"}}`}, + wantKey: "path", + wantValue: wantPath, + }, + { + name: "dry-run", + args: []string{"api", "/v1/environments/{environment_id}/customers/{customer_id}", + "--api-url", server.URL, + "--params", params, + "--dry-run"}, + wantKey: "url", + wantValue: server.URL + wantPath, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout, _, err := executeCommand(tt.args...) + 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: %v\nstdout: %s", err, stdout) + } + if result[tt.wantKey] != tt.wantValue { + t.Errorf("expected %s=%s, got %v", tt.wantKey, tt.wantValue, result[tt.wantKey]) + } + }) + } +} + +func TestAPI_CustomerIDAllowsEmailIdentifier(t *testing.T) { + server, cleanup := setupAPITest(t) + defer cleanup() + + stdout, _, err := executeCommand("api", "/v1/environments/{environment_id}/customers/{customer_id}", + "--api-url", server.URL, + "--params", `{"environment_id": "217838", "customer_id": "user@example.com"}`) + 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: %v\nstdout: %s", err, stdout) + } + if result["path"] != "/v1/environments/217838/customers/user@example.com" { + t.Errorf("expected email identifier in path, got %v", result["path"]) + } + if result["request_uri"] != "/v1/environments/217838/customers/user@example.com" { + t.Errorf("expected email identifier to remain in request path, got request_uri=%v", result["request_uri"]) + } +} + +func TestAPI_PathParamRejectsReservedPathCharacters(t *testing.T) { + server, cleanup := setupAPITest(t) + defer cleanup() + + tests := []struct { + name string + params string + }{ + { + name: "slash", + params: `{"environment_id": "217838", "test_user_id": "eea50d000102/../deliveries"}`, + }, + { + name: "pre-encoded", + params: `{"environment_id": "217838", "test_user_id": "user%40example.com"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := executeCommand("api", "/v1/environments/{environment_id}/test_users/{test_user_id}", + "--api-url", server.URL, + "--params", tt.params) + if err == nil { + t.Fatal("expected error for reserved path character") + } + if !strings.Contains(err.Error(), "reserved path character") { + t.Errorf("expected reserved path character error, got: %v", err) + } + }) + } +} + +func TestAPI_PathParamRejectsDotSegments(t *testing.T) { + server, cleanup := setupAPITest(t) + defer cleanup() + + for _, value := range []string{".", ".."} { + t.Run(value, func(t *testing.T) { + _, _, err := executeCommand("api", "/v1/environments/{environment_id}/test_users/{test_user_id}", + "--api-url", server.URL, + "--params", `{"environment_id": "217838", "test_user_id": "`+value+`"}`) + if err == nil { + t.Fatal("expected error for dot path segment") + } + if !strings.Contains(err.Error(), "dot path segment") { + t.Errorf("expected dot path segment error, got: %v", err) + } + }) + } +} + +func TestAPI_RejectsQueryOrFragmentInPathTemplate(t *testing.T) { + server, cleanup := setupAPITest(t) + defer cleanup() + + tests := []string{ + "/v1/environments/{environment_id}/campaigns?include_archived=true", + "/v1/environments/{environment_id}/campaigns#section", + } + + for _, path := range tests { + t.Run(path, func(t *testing.T) { + _, _, err := executeCommand("api", path, + "--api-url", server.URL, + "--params", `{"environment_id": "217838"}`) + if err == nil { + t.Fatal("expected error for query or fragment in path") + } + if !strings.Contains(err.Error(), "query or fragment") { + t.Errorf("expected query or fragment error, got: %v", err) + } + }) } } diff --git a/cmd/prime_context.md b/cmd/prime_context.md index 1619737..e2b1460 100644 --- a/cmd/prime_context.md +++ b/cmd/prime_context.md @@ -3,7 +3,7 @@ You have access to `cio`, an agent-first CLI for Customer.io. Most commands retu ## Key Rules 1. Unless you are intentionally using `cio prime`, assume command output is JSON. Parse it, don't regex it. -2. Resource IDs are integers (but passed as strings in paths and params). +2. Path placeholders are passed as strings. Many IDs are numeric, but profile/object IDs can be string values such as `eea50d000102`; let the API own endpoint-specific ID semantics. 3. ALWAYS use `--jq` on read calls to limit output and save tokens. 4. ALWAYS use `--dry-run` before any mutating call to preview what will be sent. 5. Read the relevant skill (`cio skills read`) BEFORE making complex API calls — the API has non-obvious required fields, multi-step workflows, and silent failures. diff --git a/internal/validate/ids.go b/internal/validate/ids.go index 46ba0f1..6723efe 100644 --- a/internal/validate/ids.go +++ b/internal/validate/ids.go @@ -14,7 +14,7 @@ func ValidateResourceID(id string) error { } for i, r := range id { - if r < 0x20 { + if r < 0x20 || r == 0x7F { return &IDValidationError{ Field: "id", Value: id, @@ -28,7 +28,7 @@ func ValidateResourceID(id string) error { return &IDValidationError{ Field: "id", Value: id, - Reason: fmt.Sprintf("ID must be numeric, contains '%c'", banned), + Reason: fmt.Sprintf("ID contains reserved path character '%c'", banned), } } } @@ -46,6 +46,41 @@ func ValidateResourceID(id string) error { return nil } +// ValidatePathSegmentID validates that id is a non-empty, single path segment. +// It is intentionally semantic-free for generic API path placeholders, while +// still rejecting characters that could smuggle query fragments, traversal, or +// pre-encoded path data. +func ValidatePathSegmentID(id string) error { + if id == "" { + return &IDValidationError{Field: "id", Value: id, Reason: "ID must not be empty"} + } + if id == "." || id == ".." { + return &IDValidationError{Field: "id", Value: id, Reason: "ID must not be a dot path segment"} + } + + for i, r := range id { + if r < 0x20 || r == 0x7F { + return &IDValidationError{ + Field: "id", + Value: id, + Reason: fmt.Sprintf("ID contains control character at position %d (U+%04X)", i, r), + } + } + } + + for _, banned := range invalidIDChars { + if strings.ContainsRune(id, banned) { + return &IDValidationError{ + Field: "id", + Value: id, + Reason: fmt.Sprintf("ID contains reserved path character '%c'", banned), + } + } + } + + return nil +} + // IDValidationError provides structured details for ID validation failures. type IDValidationError struct { Field string `json:"field"` diff --git a/skills/cio/SKILL.md b/skills/cio/SKILL.md index 20bc3d3..6ec673c 100644 --- a/skills/cio/SKILL.md +++ b/skills/cio/SKILL.md @@ -1,21 +1,27 @@ --- name: cio description: > - Customer.io CLI — use for any Customer.io, Journeys, or CDP Pipelines task. - Handles campaigns, broadcasts, segments, people, environments, subscription - topics, signing secrets, sources, destinations, track/identify events, - `sa_live_` tokens, and `fly.customer.io` / `cdp.customer.io` errors. - Trigger even when the user doesn't name the CLI. + Customer.io CLI — use for Customer.io, Journeys, or CDP Pipelines tasks, + including getting started phrases like "I want to build with Customer.io", + onboarding, signup, sending a first email, campaigns, broadcasts, segments, + people, environments, billing, pricing, plans, signing secrets, sources, + destinations, track/identify events, `sa_live_` tokens, and + `fly.customer.io` / `cdp.customer.io` errors, even when the user does not + name the CLI. --- # Customer.io (`cio`) -Run `cio prime` first. It dumps the full LLM-ready reference — commands, -flags, schemas, pagination, error shapes — so you don't have to guess or +Run `cio prime` first. It dumps the full LLM-ready reference -- commands, +flags, schemas, pagination, error shapes -- so you don't have to guess or spelunk through `--help`. +If `cio` is not installed, run `npm i -g @customerio/cli`. After installing, +run `cio prime`. If installation fails, fetch the README for other install +options: + ```bash -cio prime +curl -fsSL https://raw.githubusercontent.com/customerio/cli/main/README.md ``` Everything below handles setup cases you might hit. @@ -32,6 +38,18 @@ Examples: "I want to build with Customer.io", "help me set up Customer.io", "I want to try Customer.io", "set up a new account", "send my first email". +## If the user asks about plans, pricing, billing, or going live + +Follow [billing.md](billing.md) when the user asks what plan they are on, +whether they need to pay, how to launch, how to unlock external sends, or +anything about billing, subscriptions, credits, invoices, receipts, +payment methods, or plan limits. This includes billing questions that come up +during onboarding. + +Examples: "do I need to purchase a plan to go live?", "what does my plan +include?", "how much does this cost?", "can I send to real customers?", +"upgrade my plan", "buy credits", "what plan am I on?". + ## If the user wants to integrate an SDK Follow [integration.md](integration.md) only when the user @@ -40,16 +58,6 @@ set up in-app messaging, push notifications, or connect their codebase to Customer.io. The distinction is that they already have a working account and want to wire it into code. -## If `cio` isn't installed - -If `command -v cio` returns nothing, fetch the current install options -from the README (source of truth — this skill doesn't list them so it -can't rot), show the user the exact command, and ask before running it: - -```bash -curl -fsSL https://raw.githubusercontent.com/customerio/cli/main/README.md -``` - ## If a command fails with an auth error `cio auth login` auto-discovers the data center, so don't pass a region diff --git a/skills/cio/billing.md b/skills/cio/billing.md new file mode 100644 index 0000000..7fdcb5a --- /dev/null +++ b/skills/cio/billing.md @@ -0,0 +1,106 @@ +# Billing: Plans, pricing, and go-live costs + +Use this when the user asks about pricing, plans, current limits, billing +status, invoices, receipts, payment methods, credits, or whether they need to +pay before launching. + +Keep the answer short, account-specific, and grounded in current sources. Do not +paste raw billing JSON unless the user asks for it. Do not quote prices, +minimums, message rates, or allowances from memory. + +## Workflow + +1. Check the account's current billing state. +2. If the account is on Builder, check the Builders page. Otherwise check the + current pricing page for the relevant plan or product path. +3. Explain what the user's current plan allows for their goal. +4. Point them to the next product-supported step in Account Settings, the + pricing page, or sales. + +Do not assume the user is on Builder, Essentials, Premium, or any other plan. +Do not infer a payment path from route names alone. +Do not say Builder is only for testing. Builder has a test mode and a +pay-as-you-send go-live path; use the current Builders page for the exact +minimum, rate, and unlock steps. + +## Check Billing State + +Get the account ID if you need it: + +```bash +cio auth status --jq '.account_id' +``` + +Then inspect billing. Match `subscription.plan_id` to `plans[].id`; do not +treat every item in `plans` as the current plan. + +```bash +cio api /v1/accounts/{account_id}/billing/info \ + --jq '. as $b | { + subscription: $b.subscription, + billing_info: ($b.billing_info | { + active, + active_subscription, + basic_trial, + premium_trial, + billing_estimates, + estimated_invoice_cost, + overdue, + has_failed_overage_charge, + can_retry_payment + }), + current_plan: ([$b.plans[]? | select(.id == ($b.subscription.plan_id // null) or .current == true)] | first) + }' +``` + +If the plan is still unclear, list account plan options: + +```bash +cio api /v1/accounts/{account_id}/billing/plans +``` + +Translate the result into plain language: + +- current plan or billing model +- active, trial, or inactive subscription state +- limits, allowances, or coverage shown for the account +- current usage or overage estimate, if relevant +- the next step shown by the product or current pricing page + +Do not call `tiers` or `tiers_count` usage, limits, or overages. Tiers are plan +pricing rows unless an endpoint explicitly labels them as current account usage. +Do not turn boolean flags like `has_overage` into a bill estimate by themselves; +use `billing_estimates` or an explicit overage endpoint for amounts. + +## Check Current Pricing + +Use WebFetch, a browser, or a search/docs tool to read these pages before +quoting exact pricing, limits, minimum purchases, or rates: + +- Builder / pay-as-you-send: https://customer.io/builders +- Paid plans: https://customer.io/pricing +- Billing mechanics and overages: https://docs.customer.io/accounts-and-workspaces/how-we-bill/ + +Do not scrape marketing pages with `curl`, `grep`, or partial HTML and then +guess. If you cannot read the page, link the source and avoid exact prices. + +## Going Live + +Say what the current account state means for the launch the user described: + +- If they can continue testing, say so. +- If `current_plan.name`, `display_name`, or `plan_type` is Builder, check the + Builders page first. Do not say Builder is testing-only or that Builder must + upgrade to a paid plan to send externally; use the Builders page for the + current pay-as-you-send/external-send unlock path. If you cannot read the + page, link it and say you could not verify exact current pricing. +- If the product directs them to upgrade, say which upgrade path the current + pricing page shows and link to that flow. +- If they need a payment method, billing access, or sales contact, say that. +- If they ask for exact cost, quote the current pricing source you checked and + link to it. + +Do not issue `POST`, `PUT`, `PATCH`, or `DELETE` requests to billing endpoints. +If the user wants to change billing, direct them to the Customer.io billing UI, +pricing page, or sales path unless a product owner explicitly instructs +otherwise. diff --git a/skills/cio/onboarding.md b/skills/cio/onboarding.md index 517d270..9d1603e 100644 --- a/skills/cio/onboarding.md +++ b/skills/cio/onboarding.md @@ -14,19 +14,13 @@ Present these as suggestions and **wait for the user to confirm or override befo ## Before you start -1. Confirm `cio` is installed and on PATH: - ```bash - command -v cio - ``` - If missing, fetch install instructions: `curl -fsSL https://raw.githubusercontent.com/customerio/cli/main/README.md`. After install, re-run `command -v cio` to confirm the binary is on PATH. - -2. Load the full CLI reference: +1. Load the full CLI reference: ```bash cio prime ``` -3. Check where the user is and skip completed steps (tell the user what you're skipping and why): - - `cio auth status` returns `"verified": true` -> skip to Step 2 +2. Check where the user is and skip completed steps (tell the user what you're skipping and why): + - `cio auth status` returns `"verified": true` -> skip to Step 2; note the authenticated email for Step 5 - Already knows their environment ID -> skip to Step 3 - Already has a verified sending domain -> skip to Step 4 @@ -62,7 +56,7 @@ cio auth signup verify --json '{ "company_name": "", "first_name": "", "last_name": "", - "data_center": "us" + "data_center": "" }' ``` @@ -123,15 +117,15 @@ Ask which domain they'll send from. Suggest a domain from the company name if kn cio domains --env-id add ``` -The response includes a `domain_connect_url` — ignore it. Always use `cio domains configure` (next step) for DNS setup, which goes through Entri and supports link tracking. +The response includes a `domain_connect_url` — ignore it. Always use `cio domains configure` (next step) for the one-click DNS setup flow, which supports link tracking. -### 3b. Configure DNS via Entri +### 3b. Configure DNS with one-click setup ```bash cio domains --env-id configure ``` -Prints a URL for the Entri DNS setup flow (auto-configures MX, SPF, DKIM, DMARC). Tell the user to open it. +Prints a URL for the one-click DNS setup flow (auto-configures MX, SPF, DKIM, DMARC). Tell the user to open it. **Link tracking (optional):** Only pass `--cname` if the user specifically asks for link tracking. When used, it requires a subdomain value (e.g. `--cname email` for `email.`, or `--cname track` for `track.`). Do not pass `--cname` by default. @@ -159,7 +153,7 @@ cio domains --env-id from_addresses add \ ## Step 5 -- Send a test email -Send a test email to the authenticated user. +Send a test email to the email the user signed up with or authenticated as — it is automatically opted in. **Note:** The send command uses `--environment-id` (not `--env-id` like domain commands). Pass `--from` as a bare email address with no display name. @@ -176,9 +170,17 @@ Personalize subject/body with the company name. Email may land in spam until DNS --- +## Step 6 -- Explain go-live costs when the user is ready + +The onboarding finish line is still a test email. If the user asks about +launching, sending to real customers, plan limits, or go-live costs, follow +[billing.md](billing.md). + +--- + ## Done -Now help the user integrate Customer.io into their app: +Now help the user integrate Customer.io into their app with the starter workflows below: - **In-app messages**: identify where to add the web or mobile SDK for in-app messaging - **Push notifications**: set up their mobile app for push via the mobile SDK