Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions cmd/prime_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -96,8 +96,13 @@ cio skills read design-studio/nodes.md # node creation, component markup
| Flag | Description |
|------|-------------|
| `--params <json>` | Path + query parameters as JSON object |
| `--json <payload>` | JSON request body, `@filename` to read from a file, or `-` to read from stdin |
| `--jq <expr>` | Filter output with jq expressions (via gojq) |
| `--json <payload>` | JSON request body (`@filename` / `-` for stdin). With `--arg`/`--argjson` present, it is evaluated as a jq program that builds the body. |
| `--jq <expr>` | Filter output with a jq expression (bundled gojq) |
| `-r, --raw-output` | With `--jq`, print string results unquoted, like `jq -r` (no external jq) |
| `--arg <name=value>` | Bind a string variable for the `--json` jq program (repeatable) |
| `--argjson <name=json>` | Bind a JSON variable for the `--json` jq program (repeatable) |
| `--rawfile <name=path>` | Bind a file's contents as a string variable for `--json` (repeatable) |
| `--slurpfile <name=path>` | 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 |
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<json> (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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

import (
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -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(`<x>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=<x>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")
}
}
69 changes: 69 additions & 0 deletions internal/output/jq.go
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
46 changes: 46 additions & 0 deletions internal/output/raw_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<x-base>Hi "there"</x-base>`}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := `{"template":{"body":"<x-base>Hi \"there\"</x-base>"}}`
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")
}
})
}