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
51 changes: 51 additions & 0 deletions cmd/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,57 @@ func TestAPI_JSONFromFile_InvalidJSON(t *testing.T) {
}
}

func TestAPI_JSONFromStdin_DryRun(t *testing.T) {
server, cleanup := setupAPITest(t)
defer cleanup()

body := `{"campaign":{"name":"Test"}}`
rootCmd.SetIn(strings.NewReader(body))
t.Cleanup(func() { rootCmd.SetIn(nil) })

stdout, _, err := executeCommand("api", "/v1/environments/{environment_id}/campaigns",
"--api-url", server.URL,
"--params", `{"environment_id": "456"}`,
"--json", "-", "--dry-run")
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["dry_run"] != true {
t.Error("expected dry_run=true")
}
got, err := json.Marshal(result["body"])
if err != nil {
t.Fatalf("re-marshal body: %v", err)
}
if string(got) != body {
t.Errorf("body = %s, want %s", got, body)
}
}

func TestAPI_JSONFromStdin_Empty(t *testing.T) {
server, cleanup := setupAPITest(t)
defer cleanup()

rootCmd.SetIn(strings.NewReader(""))
t.Cleanup(func() { rootCmd.SetIn(nil) })

_, _, err := executeCommand("api", "/v1/environments/{environment_id}/campaigns",
"--api-url", server.URL,
"--params", `{"environment_id": "456"}`,
"--json", "-")
if err == nil {
t.Fatal("expected error for empty stdin")
}
if !strings.Contains(err.Error(), "must not be empty") {
t.Errorf("expected 'must not be empty' error, got: %v", err)
}
}

func TestAPI_JSONFromFile_EmptyFilename(t *testing.T) {
server, cleanup := setupAPITest(t)
defer cleanup()
Expand Down
7 changes: 6 additions & 1 deletion cmd/prime_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ 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 or `@filename` to read from file |
| `--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) |
| `-X, --method` | HTTP method override (default: GET, or POST if `--json` is provided) |
| `--dry-run` | Validate and print the request without executing |
Expand Down Expand Up @@ -116,6 +116,11 @@ cio api /v1/environments/{environment_id}/campaigns \
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 \
--params '{"environment_id": "1"}' \
--json -
```

## Filtering Large Responses
Expand Down
17 changes: 13 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"context"
"fmt"
"io"
"os"
"strings"
"time"
Expand Down Expand Up @@ -63,7 +64,7 @@ func init() {

flags := rootCmd.PersistentFlags()

flags.String("json", "", "Raw JSON request body or @filename to read from file")
flags.String("json", "", "Raw JSON request body, @filename to read from a file, or - to read from stdin")
flags.String("params", "", "Query parameters as JSON, converted to query string for GET")
flags.String("jq", "", "jq expression filter (via gojq)")
flags.Bool("dry-run", false, "Validate and print request, don't execute")
Expand Down Expand Up @@ -100,7 +101,7 @@ func init() {
// Resolve and validate --json if provided.
jsonFlag, _ := cmd.Flags().GetString("json")
if jsonFlag != "" {
resolved, err := resolveJSONFlag(jsonFlag)
resolved, err := resolveJSONFlag(jsonFlag, cmd.InOrStdin())
if err != nil {
output.PrintError(output.CodeValidationError, err.Error(), map[string]string{
"flag": "--json",
Expand Down Expand Up @@ -240,8 +241,16 @@ func SetVersion(v string) {
}
}

// resolveJSONFlag reads the file if value starts with "@", otherwise returns as-is.
func resolveJSONFlag(value string) (string, error) {
// resolveJSONFlag reads stdin if value is exactly "-", a file if it starts with
// "@", otherwise returns the value as-is.
func resolveJSONFlag(value string, stdin io.Reader) (string, error) {
if value == "-" {
data, err := io.ReadAll(stdin)
if err != nil {
return "", fmt.Errorf("--json -: %w", err)
}
return string(data), nil
}
if !strings.HasPrefix(value, "@") {
return value, nil
}
Expand Down
31 changes: 22 additions & 9 deletions cmd/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ func loadSkills(cmd *cobra.Command) (*skills.SkillsResponse, error) {
output.PrintError(output.CodeGeneralError, fmt.Sprintf("failed to load skills: %v", err), nil)
return nil, err
}
for _, notice := range resp.Notices {
fmt.Fprintln(cmd.ErrOrStderr(), "cio: "+notice)
}
return resp, nil
}

Expand All @@ -86,18 +89,26 @@ func runSkillsList(cmd *cobra.Command, args []string) error {
return err
}

type fileSummary struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}

type skillSummary struct {
Path string `json:"path"`
Name string `json:"name"`
Description string `json:"description"`
Files []string `json:"files,omitempty"`
Path string `json:"path"`
Name string `json:"name"`
Description string `json:"description"`
Files []fileSummary `json:"files,omitempty"`
}

var result []skillSummary
for _, s := range resp.Skills {
files := make([]string, 0, len(s.Files))
for f := range s.Files {
files = append(files, f)
files := make([]fileSummary, 0, len(s.Files))
for _, name := range s.SortedFiles() {
files = append(files, fileSummary{
Name: name,
Description: skills.FrontmatterDescription(s.Files[name]),
})
}
result = append(result, skillSummary{
Path: s.Path,
Expand Down Expand Up @@ -127,11 +138,13 @@ func runSkillsRead(cmd *cobra.Command, args []string) error {
}

if subFile == "" {
// Return the main SKILL.md content.
// Return the skill's routing index: authored SKILL.md content if
// present, otherwise an index synthesized from the sub-files'
// frontmatter descriptions.
return skillsOutput(cmd, map[string]any{
"path": s.Path,
"name": s.Name,
"content": s.Content,
"content": s.Index(),
})
}

Expand Down
87 changes: 85 additions & 2 deletions cmd/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ func testSkillsServer(t *testing.T) *httptest.Server {
Content: "# Liquid\n\nLiquid content.",
Files: map[string]string{},
},
{
// Entrypoint-less skill: empty Content, routing index is
// synthesized client-side from each sub-file's frontmatter.
Path: "cli",
Name: "Customer.io CLI Onboarding",
Description: "Builder onboarding.",
Content: "",
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",
},
},
},
}
data, err := json.Marshal(resp)
Expand Down Expand Up @@ -86,14 +98,85 @@ func TestSkillsList(t *testing.T) {
t.Fatalf("invalid JSON: %v\noutput: %s", err, out)
}

if len(result) != 2 {
t.Fatalf("expected 2 skills, got %d", len(result))
if len(result) != 3 {
t.Fatalf("expected 3 skills, got %d", len(result))
}
if result[0]["path"] != "fly-api" {
t.Errorf("expected first skill path 'fly-api', got %v", result[0]["path"])
}
}

func TestSkillsListIncludesFileDescriptions(t *testing.T) {
srv := testSkillsServer(t)
defer srv.Close()

out, err := runSkillsCommand(t, srv)
if err != nil {
t.Fatal(err)
}

var result []struct {
Path string `json:"path"`
Files []struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"files"`
}
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("invalid JSON: %v\noutput: %s", err, out)
}

var cli *struct {
Path string `json:"path"`
Files []struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"files"`
}
for i := range result {
if result[i].Path == "cli" {
cli = &result[i]
}
}
if cli == nil {
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)
}
if cli.Files[1].Description != "Builder onboarding entry point." {
t.Errorf("expected onboarding.md description from frontmatter, got %q", cli.Files[1].Description)
}
}

func TestSkillsReadSynthesizesIndex(t *testing.T) {
srv := testSkillsServer(t)
defer srv.Close()

out, err := runSkillsCommand(t, srv, "read", "cli")
if err != nil {
t.Fatal(err)
}

var result map[string]any
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("invalid JSON: %v\noutput: %s", err, out)
}

content, _ := result["content"].(string)
for _, want := range []string{
"# Customer.io CLI Onboarding",
"cio skills read cli/<file>",
"- **auth.md** - cio CLI authentication reference.",
"- **onboarding.md** - Builder onboarding entry point.",
} {
if !bytes.Contains([]byte(content), []byte(want)) {
t.Errorf("synthesized index missing %q\ngot:\n%s", want, content)
}
}
}

func TestSkillsRead(t *testing.T) {
srv := testSkillsServer(t)
defer srv.Close()
Expand Down
Loading