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
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
- **Keep the CLI surface 1:1 with the [API
endpoints](https://emailable.com/docs/api/).** Don't split one endpoint into
multiple subcommands or merge two into one without a reason.
- **Comment the "why," not the "what."** A comment should explain a non-obvious
trade-off, gotcha, or spec quirk — not restate what the code plainly does or
repeat a function signature. Never narrate what another package or command
does ("login writes here, logout clears there"); those references silently rot
when the other code moves. Keep one concise doc line on exported symbols.
- **Comments are a last resort — write self-documenting code.** Never leave a
comment unless it's absolutely necessary. Name things well and structure code
so it explains itself; reach for a comment only when something is genuinely
confusing and the code truly can't convey it — a surprising trade-off, gotcha,
or spec quirk. Never restate what the code does, repeat a signature, or
narrate another package or command ("login writes here, logout clears there");
those rot when the other code moves.
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,23 @@ unchanged — the CLI doesn't re-shape or add fields. See the API docs for
the field reference. Error payloads and NDJSON stream events (below) are
CLI-specific.

### Filtering with `--jq`

Pass a [jq](https://jqlang.github.io/jq/) expression to `--jq` to filter the
JSON output in place — no external `jq` binary required (handy on Windows and
in minimal containers). `--jq` implies `--json`.

```bash
emailable verify jarrett@emailable.com --jq '.state'
emailable account status --jq '.available_credits'
emailable batch get 5cfc... --jq '.emails[] | select(.state == "deliverable") | .email'
```

A string result is printed raw (unquoted, one per line), like `jq -r`, so it
drops straight into a script. Objects and arrays are printed as JSON. Combined
with `--stream`, the filter runs against each NDJSON event as it arrives (see
below).

### NDJSON streaming

`batch verify --stream` and `batch get --stream` emit one JSON object per
Expand All @@ -284,6 +301,15 @@ emailable batch verify emails.csv --stream
{"event":"complete","id":"5cfc...","status":"complete","reason_counts":{...},"emails":[...]}
```

Add `--jq` to filter each event as it streams. The filter sees the event
envelope (`event`, `id`, …), so guard on the event type; events the filter
doesn't match are skipped:

```bash
emailable batch verify emails.csv --stream \
--jq 'select(.event == "complete") | .emails[] | .email'
```

### Errors

On failure the CLI exits non-zero and writes a single line to stderr (stdout
Expand Down
2 changes: 1 addition & 1 deletion cmd/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func newAccountCmd() *cobra.Command {
OwnerEmail: a.OwnerEmail,
AvailableCredits: a.AvailableCredits,
}
return output.New(cmd.OutOrStdout(), jsonOutput).Print(view)
return newOutput(cmd.OutOrStdout(), jsonOutput).Print(view)
},
}

Expand Down
29 changes: 11 additions & 18 deletions cmd/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -135,7 +136,7 @@ func newBatchCmd() *cobra.Command {
return err
}

f := output.New(cmd.OutOrStdout(), jsonEff)
f := newOutput(cmd.OutOrStdout(), jsonEff)

submit, err := client.SubmitBatch(cmd.Context(), emails, submitOpts)
if err != nil {
Expand Down Expand Up @@ -230,32 +231,24 @@ func printBatchID(w io.Writer, id string) {
fmt.Fprintf(w, "%s %s\n", label, id)
}

// batchStreamer emits NDJSON progress events (one JSON object per line) while
// a batch is polled, so a consumer sees movement instead of one object at the
// end. Each event carries an "event" discriminator plus the batch id.
type batchStreamer struct {
w io.Writer
f *output.JSON
}

// newStreamerIfEnabled returns a batchStreamer writing to the command's stdout
// when stream is true, otherwise nil.
func newStreamerIfEnabled(cmd *cobra.Command, stream bool) *batchStreamer {
if !stream {
return nil
}
return &batchStreamer{w: cmd.OutOrStdout()}
return &batchStreamer{f: &output.JSON{W: cmd.OutOrStdout(), Compact: true, Query: jqQuery}}
}

// emit writes payload as one JSON line followed by a newline.
func (s *batchStreamer) emit(payload map[string]any) error {
b, err := json.Marshal(payload)
if err != nil {
return err
}
if _, err := s.w.Write(b); err != nil {
return err
err := s.f.Print(payload)
// A --jq filter that errors on an event skips it, never aborting the stream.
var fe *output.FilterError
if errors.As(err, &fe) {
return nil
}
_, err = s.w.Write([]byte("\n"))
return err
}

Expand Down Expand Up @@ -485,12 +478,12 @@ func renderBatchOutcome(cmd *cobra.Command, cctx *cmdCtx, status *api.BatchStatu
return saveToFile(cmd, cctx, status, outPath)
}
if cctx.JSONMode {
return output.New(cmd.OutOrStdout(), true).Print(status)
return newOutput(cmd.OutOrStdout(), true).Print(status)
}
if status.DownloadFile != "" || len(status.Emails) == 0 {
// >1000-emails download case, or a still-processing batch with no
// per-email results. Defer to the formatter's dispatch.
return output.New(cmd.OutOrStdout(), false).Print(status)
return newOutput(cmd.OutOrStdout(), false).Print(status)
}

h := &output.Human{W: cmd.OutOrStdout(), Quiet: cctx.Quiet}
Expand Down
5 changes: 3 additions & 2 deletions cmd/batch_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"testing"

"github.com/emailable/emailable-cli/internal/api"
"github.com/emailable/emailable-cli/internal/output"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -536,7 +537,7 @@ func TestSubmitBatchOptionsFromFlags_AllSet(t *testing.T) {
// through the network path.
func TestBatchStreamer_Events(t *testing.T) {
var buf bytes.Buffer
s := &batchStreamer{w: &buf}
s := &batchStreamer{f: &output.JSON{W: &buf, Compact: true}}
if err := s.emitSubmitted("bch_x"); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -569,7 +570,7 @@ func TestBatchStreamer_Events(t *testing.T) {
// emitComplete (DownloadFile populated, Emails empty).
func TestBatchStreamer_CompleteDownloadFile(t *testing.T) {
var buf bytes.Buffer
s := &batchStreamer{w: &buf}
s := &batchStreamer{f: &output.JSON{W: &buf, Compact: true}}
if err := s.emitComplete("bch_big", &api.BatchStatus{
DownloadFile: "https://files.example/big.csv",
}); err != nil {
Expand Down
129 changes: 129 additions & 0 deletions cmd/jq_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package cmd

import (
"bytes"
"strings"
"testing"

"github.com/emailable/emailable-cli/internal/output"
)

// resetJQ saves and clears the package-level --jq state around a test.
func resetJQ(t *testing.T) {
t.Helper()
prevExpr, prevQuery := jqExpr, jqQuery
jqExpr, jqQuery = "", nil
t.Cleanup(func() { jqExpr, jqQuery = prevExpr, prevQuery })
}

func TestJQ_ImpliesJSONAndFilters(t *testing.T) {
resetJSONFlag(t)
resetJQ(t)
clearEnvOverrides(t)

cmd := newRootCmd("0.1.0-test")
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.SetArgs([]string{"version", "--jq", ".version"})

if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
got := buf.String()
if got != "0.1.0-test\n" {
t.Errorf("got %q, want %q", got, "0.1.0-test\n")
}
if strings.Contains(got, "{") {
t.Errorf("filtered string result should not be a JSON document, got %q", got)
}
}

func TestJQ_NoStaleQueryAcrossRuns(t *testing.T) {
resetJSONFlag(t)
resetJQ(t)
clearEnvOverrides(t)

first := newRootCmd("0.1.0-test")
var fb bytes.Buffer
first.SetOut(&fb)
first.SetErr(&fb)
first.SetArgs([]string{"version", "--jq", ".version"})
if err := first.Execute(); err != nil {
t.Fatalf("first execute: %v", err)
}

second := newRootCmd("0.1.0-test")
var sb bytes.Buffer
second.SetOut(&sb)
second.SetErr(&sb)
second.SetArgs([]string{"version", "--json"})
if err := second.Execute(); err != nil {
t.Fatalf("second execute: %v", err)
}
// Second run had no --jq, so it must emit the full document, not a scalar.
if !strings.Contains(sb.String(), "{") {
t.Errorf("second run should emit a full JSON object, got %q", sb.String())
}
}

func TestJQ_BadExpression(t *testing.T) {
resetJSONFlag(t)
resetJQ(t)
clearEnvOverrides(t)

cmd := newRootCmd("0.1.0-test")
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.SetArgs([]string{"version", "--jq", ".["})

if err := cmd.Execute(); err == nil {
t.Fatal("expected an error for a malformed --jq expression")
}
// --jq implies --json even when the expression fails to compile, so the
// error renders as JSON rather than a human line.
if !jsonOutput {
t.Error("a bad --jq expression should still leave JSON mode enabled")
}
}

func TestJQ_FiltersStreamEvents(t *testing.T) {
q := mustCompile(t, ".id")
var buf bytes.Buffer
s := &batchStreamer{f: &output.JSON{W: &buf, Compact: true, Query: q}}

if err := s.emitSubmitted("bch_x"); err != nil {
t.Fatal(err)
}
if err := s.emitProgress("bch_x", 1, 10); err != nil {
t.Fatal(err)
}
// Both events carry an id, so each yields the raw id on its own line.
if got := buf.String(); got != "bch_x\nbch_x\n" {
t.Errorf("got %q, want %q", got, "bch_x\nbch_x\n")
}
}

func TestJQ_StreamSkipsFilterErrors(t *testing.T) {
// .emails[] errors on a progress event (no emails); it must be skipped.
q := mustCompile(t, ".emails[]")
var buf bytes.Buffer
s := &batchStreamer{f: &output.JSON{W: &buf, Compact: true, Query: q}}

if err := s.emitProgress("bch_x", 1, 10); err != nil {
t.Fatalf("progress event should be skipped, not error: %v", err)
}
if buf.Len() != 0 {
t.Errorf("expected no output for a skipped event, got %q", buf.String())
}
}

func mustCompile(t *testing.T, expr string) *output.Query {
t.Helper()
q, err := output.CompileQuery(expr)
if err != nil {
t.Fatalf("CompileQuery(%q): %v", expr, err)
}
return q
}
2 changes: 1 addition & 1 deletion cmd/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func runLogoutE(cmd *cobra.Command, _ []string) error {
}

if jsonOutput {
return (&output.JSON{W: cmd.OutOrStdout()}).Print(map[string]any{
return newJSON(cmd.OutOrStdout()).Print(map[string]any{
"logged_out": true,
"message": "Logged out.",
})
Expand Down
32 changes: 32 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/emailable/emailable-cli/internal/env"
"github.com/emailable/emailable-cli/internal/output"
"github.com/emailable/emailable-cli/internal/ui"
"github.com/emailable/emailable-cli/internal/updater"
"github.com/spf13/cobra"
Expand All @@ -32,6 +33,23 @@ const releaseURLPrefix = "https://github.com/emailable/emailable-cli/releases/ta
// (rather than re-querying the cobra flag set) to pick an output formatter.
var jsonOutput bool

// jqExpr backs the --jq flag; jqQuery is its compiled form. --jq implies --json.
var (
jqExpr string
jqQuery *output.Query
)

func newOutput(w io.Writer, jsonMode bool) output.Formatter {
if jqQuery != nil {
return &output.JSON{W: w, Query: jqQuery}
}
return output.New(w, jsonMode)
}

func newJSON(w io.Writer) *output.JSON {
return &output.JSON{W: w, Query: jqQuery}
}

// apiKey is the value of the `login --api-key` local flag. It is deliberately
// NOT a persistent root flag: credentials on argv would leak into shell history
// and `ps` output, so a key only comes via EMAILABLE_API_KEY, stored config, or
Expand Down Expand Up @@ -205,13 +223,27 @@ func newRootCmd(v string) *cobra.Command {
jsonOutput = true
}
}
// Clear first so a query from an earlier in-process run can't leak
// into a command invoked without --jq.
jqQuery = nil
if jqExpr != "" {
// Set JSON mode before compiling so a bad-expression error
// still renders as JSON, honoring --jq's implied --json.
jsonOutput = true
q, err := output.CompileQuery(jqExpr)
if err != nil {
return NewInvalidInputf("invalid --jq expression: %v", err)
}
jqQuery = q
}
Comment thread
jclusso marked this conversation as resolved.
return nil
},
}
// Print just the blurb; versionDisplay already includes "emailable version ".
root.SetVersionTemplate("{{ .Version }}\n")

root.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Return JSON response")
root.PersistentFlags().StringVar(&jqExpr, "jq", "", "Filter JSON output with a jq `expression` (implies --json)")
root.PersistentFlags().BoolVar(&debugMode, "debug", false, "Dump HTTP requests/responses to stderr (also EMAILABLE_DEBUG)")
root.PersistentFlags().BoolVarP(&quietMode, "quiet", "q", false, "Suppress non-error human output (success lines, hints, progress)")

Expand Down
4 changes: 2 additions & 2 deletions cmd/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ func newSkillTargetsCmd() *cobra.Command {
}
rows = append(rows, row)
}
return (&output.JSON{W: cmd.OutOrStdout()}).Print(map[string]any{"targets": rows})
return newJSON(cmd.OutOrStdout()).Print(map[string]any{"targets": rows})
}
printTargetsTable(cmd.OutOrStdout(), ts)
return nil
Expand Down Expand Up @@ -253,7 +253,7 @@ func renderInstallResult(cmd *cobra.Command, res skill.Result) error {
}
links = append(links, row)
}
return (&output.JSON{W: cmd.OutOrStdout()}).Print(map[string]any{
return newJSON(cmd.OutOrStdout()).Print(map[string]any{
"skill_path": res.SkillPath,
"links": links,
})
Expand Down
2 changes: 1 addition & 1 deletion cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func runStatusE(cmd *cobra.Command, _ []string) error {
payload["expires_at"] = expiresAt
payload["expires_in"] = expiresIn
}
return (&output.JSON{W: cmd.OutOrStdout()}).Print(payload)
return newJSON(cmd.OutOrStdout()).Print(payload)
}

return printStatusHuman(cmd, cctx, source, loggedIn, expiresAt, expiresIn)
Expand Down
Loading