From 1ea64407e8e6e9dcf813ecb6a71c066a1196db2e Mon Sep 17 00:00:00 2001 From: "Customer.io Open Source Bot" Date: Tue, 16 Jun 2026 18:01:04 -0400 Subject: [PATCH] Add Customer.io CLI source CioCliPublicExport-RevId: 28cc17314beed2e5574871171817a55fd775e439 --- cmd/prime_context.md | 23 +++++++++---- cmd/root.go | 60 ++++++++++++++++++++++++++++++++ cmd/root_test.go | 25 ++++++++++++++ internal/output/jq.go | 69 +++++++++++++++++++++++++++++++++++++ internal/output/raw_test.go | 46 +++++++++++++++++++++++++ 5 files changed, 217 insertions(+), 6 deletions(-) diff --git a/cmd/prime_context.md b/cmd/prime_context.md index 8517563..e953df0 100644 --- a/cmd/prime_context.md +++ b/cmd/prime_context.md @@ -4,7 +4,7 @@ You have access to `cio`, an agent-first CLI for Customer.io. Most commands retu 1. Unless you are intentionally using `cio prime`, assume command output is JSON. Parse it, don't regex it. 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. +3. ALWAYS use `--jq` on read calls to limit output and save tokens. Add `-r` to print a raw string (an id, token, or body); use `--arg`/`--argjson` to build a request body with embedded values. The bundled gojq covers both, so you never need an external `jq`. 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. @@ -96,8 +96,13 @@ cio skills read design-studio/nodes.md # node creation, component markup | Flag | Description | |------|-------------| | `--params ` | Path + query parameters as JSON object | -| `--json ` | JSON request body, `@filename` to read from a file, or `-` to read from stdin | -| `--jq ` | Filter output with jq expressions (via gojq) | +| `--json ` | JSON request body (`@filename` / `-` for stdin). With `--arg`/`--argjson` present, it is evaluated as a jq program that builds the body. | +| `--jq ` | Filter output with a jq expression (bundled gojq) | +| `-r, --raw-output` | With `--jq`, print string results unquoted, like `jq -r` (no external jq) | +| `--arg ` | Bind a string variable for the `--json` jq program (repeatable) | +| `--argjson ` | Bind a JSON variable for the `--json` jq program (repeatable) | +| `--rawfile ` | Bind a file's contents as a string variable for `--json` (repeatable) | +| `--slurpfile ` | Bind a file's JSON contents as a variable for `--json` (repeatable) | | `-X, --method` | HTTP method override (default: GET, or POST if `--json` is provided) | | `--dry-run` | Validate and print the request without executing | | `--read-only` | Request a read-only session; only GET requests are permitted | @@ -140,10 +145,16 @@ cio api /v1/environments/{environment_id}/segments \ --params '{"environment_id": "1"}' \ --page-all --jq '{id, name}' -# Pipe the body in via stdin (avoids shell-quoting a large payload) -echo "$BODY" | cio api /v1/environments/{environment_id}/campaigns \ +# Build a body with embedded values — --arg binds the string, the --json jq +# program references it; no shell escaping, no external jq +cio api /v1/environments/{environment_id}/campaigns -X POST \ --params '{"environment_id": "1"}' \ - --json - + --arg name="Welcome, with a comma" \ + --json '{campaign:{name:$name,type:"none"}}' --dry-run + +# Extract a raw scalar (no surrounding quotes) with -r +ID=$(cio api /v1/environments/{environment_id}/campaigns \ + --params '{"environment_id": "1"}' --jq '.campaigns[0].id' -r) ``` ## Filtering Large Responses diff --git a/cmd/root.go b/cmd/root.go index 105a1a1..b2de01d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -69,6 +69,10 @@ func init() { flags.String("params", "", "Query parameters as JSON, converted to query string for GET") flags.String("jq", "", "jq expression filter (via gojq)") flags.BoolP("raw-output", "r", false, "Print string results unquoted, like jq -r (no external jq needed)") + flags.StringArray("arg", nil, "Bind a string variable for --json's jq program: --arg name=value (repeatable). Makes --json a jq -n program — no external jq needed") + flags.StringArray("argjson", nil, "Bind a JSON variable for --json's jq program: --argjson name= (repeatable)") + flags.StringArray("rawfile", nil, "Bind a file's contents as a string variable for --json: --rawfile name=path (repeatable)") + flags.StringArray("slurpfile", nil, "Bind a file's JSON contents as a variable for --json: --slurpfile name=path (repeatable)") flags.Bool("dry-run", false, "Validate and print request, don't execute") flags.Bool("read-only", false, "Request a read-only session (scope=read_only); only GET requests are permitted") flags.StringSlice("scope", nil, "Additional OAuth scope(s) to request during token exchange") @@ -123,6 +127,46 @@ func init() { }) return err } + // When --arg/--argjson are present, --json is a jq program (jq -n + // style): evaluate it with those bindings, via the bundled gojq, to + // build the body. Lets callers embed quoted/Liquid/multi-line values + // without external jq or shell escaping. + argVals, _ := cmd.Flags().GetStringArray("arg") + argjsonVals, _ := cmd.Flags().GetStringArray("argjson") + + // --rawfile name=path binds a file's contents as a string variable; + // --slurpfile binds its JSON contents. Resolve them into the same + // bindings BuildJSON already understands. + rawfiles, _ := cmd.Flags().GetStringArray("rawfile") + slurpfiles, _ := cmd.Flags().GetStringArray("slurpfile") + for _, spec := range rawfiles { + binding, ferr := fileBinding("rawfile", spec) + if ferr != nil { + output.PrintError(output.CodeValidationError, ferr.Error(), map[string]string{"flag": "--rawfile"}) + return ferr + } + argVals = append(argVals, binding) + } + for _, spec := range slurpfiles { + binding, ferr := fileBinding("slurpfile", spec) + if ferr != nil { + output.PrintError(output.CodeValidationError, ferr.Error(), map[string]string{"flag": "--slurpfile"}) + return ferr + } + argjsonVals = append(argjsonVals, binding) + } + + if len(argVals) > 0 || len(argjsonVals) > 0 { + built, buildErr := output.BuildJSON(resolved, argVals, argjsonVals) + if buildErr != nil { + output.PrintError(output.CodeValidationError, buildErr.Error(), map[string]string{ + "flag": "--json", + }) + return buildErr + } + resolved = string(built) + } + // Store the resolved value back so downstream code sees the file contents. _ = cmd.Flags().Set("json", resolved) if _, err := validate.ValidateJSONPayload(resolved); err != nil { @@ -298,6 +342,22 @@ func resolveJSONFlag(value string, stdin io.Reader) (string, error) { return string(data), nil } +// fileBinding resolves a "name=path" spec for --rawfile / --slurpfile into the +// "name=value" binding BuildJSON consumes, reading the file's contents as the +// value. The value's own "=" or newlines are preserved (BuildJSON splits on the +// first "=" only). +func fileBinding(flag, spec string) (string, error) { + name, path, found := strings.Cut(spec, "=") + if !found || name == "" { + return "", fmt.Errorf("--%s expects name=path, got %q", flag, spec) + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("--%s %s: %w", flag, name, err) + } + return name + "=" + string(data), nil +} + // Execute runs the root command. func Execute() { if err := rootCmd.Execute(); err != nil { diff --git a/cmd/root_test.go b/cmd/root_test.go index 3ce0140..158976e 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,6 +1,8 @@ package cmd import ( + "os" + "path/filepath" "strings" "testing" @@ -41,3 +43,26 @@ func TestSetVersionIgnoresEmptyVersion(t *testing.T) { t.Fatalf("useragent.Get() = %q, want %q", got, want) } } + +func TestFileBinding(t *testing.T) { + dir := t.TempDir() + raw := filepath.Join(dir, "body.txt") + if err := os.WriteFile(raw, []byte(`Hi "there"`), 0o600); err != nil { + t.Fatal(err) + } + + got, err := fileBinding("rawfile", "html="+raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := `html=Hi "there"`; got != want { + t.Errorf("binding mismatch:\n want: %q\n got: %q", want, got) + } + + if _, err := fileBinding("rawfile", "noequals"); err == nil { + t.Error("expected error for missing =") + } + if _, err := fileBinding("slurpfile", "x="+filepath.Join(dir, "missing.json")); err == nil { + t.Error("expected error for missing file") + } +} diff --git a/internal/output/jq.go b/internal/output/jq.go index bd25962..6daf55d 100644 --- a/internal/output/jq.go +++ b/internal/output/jq.go @@ -1,12 +1,81 @@ package output import ( + "bytes" "encoding/json" "fmt" + "strings" "github.com/itchyny/gojq" ) +// BuildJSON evaluates program as a jq expression against null input with the +// given variable bindings and returns the single JSON result, mirroring +// `jq -n --arg / --argjson`. args bind string values, argjson bind parsed JSON +// values; each entry is "name=value". Lets a request body be built with the +// bundled gojq, so no external jq is needed to encode embedded markup. +func BuildJSON(program string, args, argjson []string) (json.RawMessage, error) { + var names []string + var values []any + + for _, kv := range args { + name, val, err := splitArgBinding("arg", kv) + if err != nil { + return nil, err + } + names = append(names, "$"+name) + values = append(values, val) + } + for _, kv := range argjson { + name, val, err := splitArgBinding("argjson", kv) + if err != nil { + return nil, err + } + var parsed any + if err := json.Unmarshal([]byte(val), &parsed); err != nil { + return nil, fmt.Errorf("--argjson %s: value is not valid JSON: %w", name, err) + } + names = append(names, "$"+name) + values = append(values, parsed) + } + + query, err := gojq.Parse(program) + if err != nil { + return nil, fmt.Errorf("--json jq parse: %w", err) + } + code, err := gojq.Compile(query, gojq.WithVariables(names)) + if err != nil { + return nil, fmt.Errorf("--json jq compile: %w", err) + } + + iter := code.Run(nil, values...) + v, ok := iter.Next() + if !ok { + return nil, fmt.Errorf("--json jq program produced no output") + } + if err, isErr := v.(error); isErr { + return nil, fmt.Errorf("--json jq eval: %w", err) + } + // Encode without HTML escaping so markup (`<`, `>`, `&`) stays literal in the + // body, matching what `jq -n` produces. + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, fmt.Errorf("--json marshal result: %w", err) + } + return json.RawMessage(bytes.TrimRight(buf.Bytes(), "\n")), nil +} + +// splitArgBinding parses a "name=value" binding for --arg / --argjson. +func splitArgBinding(flag, kv string) (name, value string, err error) { + name, value, found := strings.Cut(kv, "=") + if !found || name == "" { + return "", "", fmt.Errorf("--%s expects name=value, got %q", flag, kv) + } + return name, value, nil +} + // ApplyJQ applies a jq expression to the given JSON data and returns the results. func ApplyJQ(data json.RawMessage, expr string) ([]json.RawMessage, error) { query, err := gojq.Parse(expr) diff --git a/internal/output/raw_test.go b/internal/output/raw_test.go index 42c7e90..2f75199 100644 --- a/internal/output/raw_test.go +++ b/internal/output/raw_test.go @@ -55,3 +55,49 @@ func TestFprintProcess_Raw(t *testing.T) { }) } } + +func TestBuildJSON(t *testing.T) { + t.Run("arg binds a string value, no shell escaping needed", func(t *testing.T) { + got, err := BuildJSON(`{template:{body:$h}}`, []string{`h=Hi "there"`}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := `{"template":{"body":"Hi \"there\""}}` + if string(got) != want { + t.Errorf("mismatch:\n want: %s\n got: %s", want, got) + } + }) + + t.Run("argjson binds parsed JSON, tostring nests it as a string", func(t *testing.T) { + got, err := BuildJSON(`{body_json:($cfg|tostring)}`, nil, []string{`cfg={"priority":10}`}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := `{"body_json":"{\"priority\":10}"}` + if string(got) != want { + t.Errorf("mismatch:\n want: %s\n got: %s", want, got) + } + }) + + t.Run("multi-line arg value is preserved", func(t *testing.T) { + got, err := BuildJSON(`{body:$h}`, []string{"h=line1\nline2"}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != `{"body":"line1\nline2"}` { + t.Errorf("got: %s", got) + } + }) + + t.Run("rejects binding without =", func(t *testing.T) { + if _, err := BuildJSON(`{a:$x}`, []string{"x"}, nil); err == nil { + t.Fatal("expected error for missing =") + } + }) + + t.Run("rejects invalid argjson", func(t *testing.T) { + if _, err := BuildJSON(`{a:$x}`, nil, []string{"x=not json"}); err == nil { + t.Fatal("expected error for invalid JSON") + } + }) +}