Skip to content
1 change: 1 addition & 0 deletions internal/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
### General

* Specification exists in the repository for historic purposes. Do not modify it and do not treat is as the source of truth.
* Keep the CLI package clean, create a file-per-command. Try extracting logic into a different package.
* Ask clarifying questions from the user if details important for the task are missing.
* When working on a bug fix, follow the RED-GREEN-BLUE TDD approach.
Expand Down
12 changes: 9 additions & 3 deletions internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ These apply to every client-mode command. Each command selects the agent it talk
| `--agent-card <ref>` | `-a` | Agent Card reference: a host/origin (the well-known path is appended), a full card URL, or a local file path. The card is resolved and a transport negotiated. |
| `--endpoint <ref>` | `-e` | Agent interface URL for a direct connection, skipping card resolution. Must be paired with exactly one `--transport`. Mutually exclusive with `--agent-card`. |
| `--transport <name>` | | Transport preference: `rest`, `jsonrpc`, `grpc`. Repeatable and ordered (highest preference first). With `--agent-card` it overrides the card's preference order; with `--endpoint` exactly one is required. |
| `--output <fmt>` | `-o` | Output format: `text` (default), `json`. |
| `--output <fmt>` | `-o` | Output format: `text` (default), `json` (indented), or `jsonl` (one compact JSON object per line). |
| `--svc-param <k=v>` | | Service parameter (repeatable). The chosen transport defines how it's passed. Split on the first `=`. |
| `--auth <creds>` | | Shorthand for `--svc-param "Authorization=<creds>"`. |
| `--tenant <id>` | | Tenant identifier. Passed on every request. |
Expand Down Expand Up @@ -309,8 +309,14 @@ StatusUpdate: completed

## Output Formatting

All commands support `-o json` for machine-readable output, emitting raw protocol objects.
Text mode is the default, meant for reading in a terminal.
All commands support machine-readable output, emitting raw protocol objects:

- `-o json` — indented JSON: a single indented document, or one indented record per event under `--stream`.
- `-o jsonl` — [JSON Lines](https://jsonlines.org/): one compact JSON object per line, ideal for piping and incremental consumption under `--stream`.

Text mode is the default, meant for reading in a terminal. The output format controls
only presentation (indentation); `--stream` independently controls whether the command
follows the agent's live events or waits for the terminal result.

## Custom Transport Plugins

Expand Down
212 changes: 191 additions & 21 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,8 @@ func TestSend(t *testing.T) {
if err != nil {
t.Fatalf("runCMD(%q) error = %v", strings.Join(tt.args(mode.url), " "), err)
}
var task a2a.Task
if err := json.Unmarshal([]byte(out), &task); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if text := testutil.AllArtifactText(&task); text != tt.wantText {
task := mustDecodeTask(t, out)
if text := testutil.AllArtifactText(task); text != tt.wantText {
t.Fatalf("allArtifactText() = %q, want %q", text, tt.wantText)
}
})
Expand Down Expand Up @@ -285,11 +282,8 @@ func TestSend_AgentCardFromFile(t *testing.T) {
if err != nil {
t.Fatalf("runCMD(%q) error = %v", strings.Join(tt.args, " "), err)
}
var task a2a.Task
if err := json.Unmarshal([]byte(out), &task); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if text := testutil.AllArtifactText(&task); text != tt.wantText {
task := mustDecodeTask(t, out)
if text := testutil.AllArtifactText(task); text != tt.wantText {
t.Fatalf("allArtifactText() = %q, want %q", text, tt.wantText)
}
})
Expand All @@ -306,11 +300,8 @@ func TestSendDataPart(t *testing.T) {
}

out := mustRunCMD(t, "send", "-a", url, "-o", "json", "--data-part", path)
var task a2a.Task
if err := json.Unmarshal([]byte(out), &task); err != nil {
t.Fatalf("json.Unmarshal(send --data-part output) error = %v", err)
}
if got := testutil.AllArtifactText(&task); got != `{"hello":"world"}` {
task := mustDecodeTask(t, out)
if got := testutil.AllArtifactText(task); got != `{"hello":"world"}` {
t.Fatalf("allArtifactText() = %q, want %q", got, `{"hello":"world"}`)
}
}
Expand All @@ -325,11 +316,8 @@ func TestSendRequestPayloadFile(t *testing.T) {
}

out := mustRunCMD(t, "send", "-a", url, "-o", "json", "--request-payload", path)
var task a2a.Task
if err := json.Unmarshal([]byte(out), &task); err != nil {
t.Fatalf("json.Unmarshal(send --request-payload output) error = %v", err)
}
if got := testutil.AllArtifactText(&task); got != "from file" {
task := mustDecodeTask(t, out)
if got := testutil.AllArtifactText(task); got != "from file" {
t.Fatalf("allArtifactText() = %q, want %q", got, "from file")
}
}
Expand Down Expand Up @@ -490,12 +478,79 @@ func TestSendStreaming(t *testing.T) {
}
}

func TestSendStreamJSONL(t *testing.T) {
t.Parallel()
url := startTestServer(t)

testCases := []struct {
name string
flags []string
wantObjectPerLine bool
}{
{
name: "jsonl streams one compact object per line",
flags: []string{"-a", url, "-o", "jsonl", "--stream"},
wantObjectPerLine: true,
},
{
name: "jsonl compact with non-streaming",
flags: []string{"-a", url, "-o", "jsonl"},
wantObjectPerLine: true,
},
{
name: "json streams indented records",
flags: []string{"-a", url, "-o", "json", "--stream"},
wantObjectPerLine: false,
},
{
name: "json indented with non-streaming",
flags: []string{"-a", url, "-o", "json"},
wantObjectPerLine: false,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
command := append([]string{"send", "stream me"}, tc.flags...)
out := mustRunCMD(t, command...)
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) == 0 {
t.Fatalf("send --stream produced no JSONL lines")
}
objectPerLine := true
for i, line := range lines {
var sr a2a.StreamResponse
if err := json.Unmarshal([]byte(line), &sr); err != nil {
if tc.wantObjectPerLine {
t.Fatalf("JSONL line %d is not an independently parseable object: %v\nline: %s", i, err, line)
}
objectPerLine = false
break
}
}
if objectPerLine && !tc.wantObjectPerLine {
t.Fatalf("all outputs lines contained a well-formed a2a.StreamResponse:\n%s", out)
}
})
}

}

func TestSendOutputInvalidFormat(t *testing.T) {
t.Parallel()
url := startTestServer(t)
if _, err := runCMD(t, "send", "-a", url, "-o", "yaml", "format me"); err == nil {
t.Fatal("send -o yaml error = nil, want error")
}
}

func TestSendStreamingFallbackUsesDefaultPoller(t *testing.T) {
t.Parallel()
nonStreamingURL := startTestServerWith(t, a2a.AgentCapabilities{Streaming: false})

out, err := runCMDWithConfig(t, deps{cfgLoader: clicfg.LoadEmpty},
"send", "-a", nonStreamingURL, "-o", "json", "--stream", "stream me", "--polling-interval", "5ms")
"send", "-a", nonStreamingURL, "-o", "json", "--stream", "stream me", "--poll-interval", "5ms")
if err != nil {
t.Fatalf("runCMDWithConfig() error = %v", err)
}
Expand All @@ -514,6 +569,108 @@ func TestSendStreamingFallbackUsesDefaultPoller(t *testing.T) {
}
}

func TestSend_ResumeHintForInputRequiredTask(t *testing.T) {
t.Parallel()

var taskID a2a.TaskID
server := httptest.NewServer(a2asrv.NewRESTHandler(a2asrv.NewHandler(
a2asrv.AgentExecutorFunc(func(ctx context.Context, ec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] {
return func(yield func(a2a.Event, error) bool) {
taskID = ec.TaskID
task := &a2a.Task{
ID: ec.TaskID,
ContextID: ec.ContextID,
Status: a2a.TaskStatus{State: a2a.TaskStateInputRequired},
}
yield(task, nil)
}
}),
)))
t.Cleanup(server.Close)

out := mustRunCMD(t, "send", "-e", server.URL, "--transport", "rest", "hello")
if !strings.Contains(out, "a2a send --task-id "+string(taskID)) {
t.Fatalf("send text output missing the resume hint:\n%s", out)
}
}

func TestSendWithVersionSelector(t *testing.T) {
t.Parallel()
url := startTestServer(t)
legacyURL := startLegacyTestServer(t)

testCases := []struct {
name string
connect []string
version string
wantErr bool
}{
{
name: "new server success",
connect: []string{"-a", url},
version: "1.0",
},
{
name: "old server success",
connect: []string{"-a", legacyURL},
version: "0.3",
},
{
name: "new server direct success",
connect: []string{"-e", url, "--transport", "rest"},
version: "1.0",
},
{
name: "old server direct success",
connect: []string{"-e", legacyURL, "--transport", "jsonrpc"},
version: "0.3",
},
{
name: "new server failure",
connect: []string{"-a", url},
version: "0.3",
wantErr: true,
},
{
name: "new server direct failure",
connect: []string{"-e", url, "--transport", "rest"},
version: "0.3",
wantErr: true,
},
{
name: "old server failure",
connect: []string{"-a", legacyURL},
version: "1.0",
wantErr: true,
},
{
name: "old server direct failure",
connect: []string{"-e", legacyURL, "--transport", "jsonrpc"},
version: "1.0",
wantErr: true,
},
{
name: "unknown version failure",
connect: []string{"-e", url},
version: "3.0",
wantErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
command := []string{"send", "--a2a-version", tc.version, "-o", "json", "hi"}
command = append(command, tc.connect...)
_, err := runCMD(t, command...)
if err != nil && !tc.wantErr {
t.Fatalf("send error = %v", err)
}
if err == nil && tc.wantErr {
t.Fatal("send error = nil, wanted a failure")
}
})
}
}

func TestGetTask(t *testing.T) {
t.Parallel()
url := startTestServer(t)
Expand Down Expand Up @@ -695,6 +852,19 @@ func startLegacyTestServer(t *testing.T) string {
return server.URL
}

func mustDecodeTask(t *testing.T, out string) *a2a.Task {
t.Helper()
var resp a2a.StreamResponse
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("json.Unmarshal() error = %v\noutput: %s", err, out)
}
task, ok := resp.Event.(*a2a.Task)
if !ok {
t.Fatalf("send output has no task wrapper: %s", out)
}
return task
}

func sendTestMessage(t *testing.T, url, text string) a2a.TaskID {
t.Helper()
ctx := t.Context()
Expand Down
30 changes: 21 additions & 9 deletions internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ func newClientFromEndpoint(ctx context.Context, cfg *globalConfig, ref string, e
}

endpoint := a2a.NewAgentInterface(endpointURL, protocol)
if cfg.a2aVersion != "" {
endpoint.ProtocolVersion = a2a.ProtocolVersion(cfg.a2aVersion)
}
client, err := a2aclient.NewFromEndpoints(ctx, []*a2a.AgentInterface{endpoint}, factoryOpts...)
return client, hintInsecure(err)
}
Expand Down Expand Up @@ -126,19 +129,28 @@ func hintInsecure(err error) error {
}

func clientFactoryOpts(cfg *globalConfig) []a2aclient.FactoryOption {
factoryOpts := []a2aclient.FactoryOption{
a2av0.WithRESTTransport(a2av0.RESTTransportConfig{}),
a2av0.WithJSONRPCTransport(a2av0.JSONRPCTransportConfig{}),
}
var grpcOpts []grpc.DialOption
if cfg.insecureGRPC {
grpcOpts = append(grpcOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
}
factoryOpts = append(factoryOpts,
a2agrpcv0.WithGRPCTransport(grpcOpts...),
a2agrpc.WithGRPCTransport(grpcOpts...),
)
return factoryOpts
opts := []a2aclient.FactoryOption{a2aclient.WithDefaultsDisabled()}
if cfg.a2aVersion == "" || cfg.a2aVersion == "1.0" {
opts = append(
opts,
a2aclient.WithRESTTransport(nil),
a2aclient.WithJSONRPCTransport(nil),
a2agrpc.WithGRPCTransport(grpcOpts...),
)
}
if cfg.a2aVersion == "" || cfg.a2aVersion == "0.3" {
opts = append(
opts,
a2av0.WithRESTTransport(a2av0.RESTTransportConfig{}),
a2av0.WithJSONRPCTransport(a2av0.JSONRPCTransportConfig{}),
a2agrpcv0.WithGRPCTransport(grpcOpts...),
)
}
return opts
}

func stripHTTPScheme(raw string) string {
Expand Down
4 changes: 1 addition & 3 deletions internal/cli/config_show.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ import (
"text/tabwriter"

"github.com/spf13/cobra"

"github.com/a2aproject/a2a-cli/internal/output"
)

type flagBindingView struct {
Expand Down Expand Up @@ -58,7 +56,7 @@ func newConfigShowCmd(cfg *globalConfig) *cobra.Command {
views = append(views, view)
}

if cfg.Mode == output.ModeJson {
if cfg.IsJSON() {
return cfg.PrintJSON(views)
}

Expand Down
8 changes: 5 additions & 3 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type globalConfig struct {
url string
transports []string
svcParams *flagparse.ServiceParams
a2aVersion string
tenant string
timeout time.Duration
verbose bool
Expand Down Expand Up @@ -102,20 +103,21 @@ func newRootCmd(cfg *globalConfig, deps deps) *cobra.Command {
cfg.bindings = bindings

switch output.Mode(cfg.output) {
case output.ModeText, output.ModeJson:
case output.ModeText, output.ModeJson, output.ModeJSONL:
cfg.Mode = output.Mode(cfg.output)
default:
return fmt.Errorf("invalid --output %q (want text or json)", cfg.output)
return fmt.Errorf("invalid --output %q (want text, json, or jsonl)", cfg.output)
}
return nil
},
}

pf := cmd.PersistentFlags()
pf.StringVarP(&cfg.output, "output", "o", "text", "Output format: text, json")
pf.StringVarP(&cfg.output, "output", "o", "text", "Output format: text, json (indented), or jsonl (one compact JSON object per line)")
pf.VarP(&cfg.agentCard, "agent-card", "a", "Agent Card reference: host/origin, full card URL, or local file path")
pf.StringVarP(&cfg.url, "endpoint", "e", "", "Agent interface URL for a direct connection; skips card resolution and requires a single --transport flag")
pf.StringArrayVar(&cfg.transports, "transport", nil, "Transport preference: rest, jsonrpc, grpc, or an installed plugin name (repeatable, highest preference first)")
pf.StringVar(&cfg.a2aVersion, "a2a-version", "", "Controls which a2a-protocol version client will advertise to the server.")
cfg.svcParams.Attach(pf)
pf.StringVar(&cfg.tenant, "tenant", "", "Tenant identifier")
pf.DurationVar(&cfg.timeout, "timeout", 30*time.Second, "Request timeout")
Expand Down
Loading
Loading