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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.25.0

require (
github.com/a2aproject/a2a-go v0.3.15
github.com/a2aproject/a2a-go/v2 v2.4.1-0.20260817112309-579cff89f7ba
github.com/a2aproject/a2a-go/v2 v2.5.0
github.com/google/go-cmp v0.7.0
github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.10.2
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
github.com/a2aproject/a2a-go v0.3.15 h1:h5YpCiPq3jxQ5rIns7oDjPag3ivP8u817AzdA4F+NiI=
github.com/a2aproject/a2a-go v0.3.15/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I=
github.com/a2aproject/a2a-go/v2 v2.4.1-0.20260817112309-579cff89f7ba h1:4EedjlTSsl92mWP9qAfa8zKHI7mLzOkMpFiSHl9Y8u0=
github.com/a2aproject/a2a-go/v2 v2.4.1-0.20260817112309-579cff89f7ba/go.mod h1:EghJ/rY9OCC6jme1z+otOBf+1YCVVCpMSPZU31t3hKY=
github.com/a2aproject/a2a-go/v2 v2.5.0 h1:ZdcFoxv+nZTUV0i2ue5hES76YCANFPG9vjqd7vK8yWM=
github.com/a2aproject/a2a-go/v2 v2.5.0/go.mod h1:NcRp/ZHxgMzDj12/BteIC2gOjljuEBKaGRfEdJ2lNSI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
Expand Down Expand Up @@ -36,6 +36,8 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
Expand Down
9 changes: 5 additions & 4 deletions internal/cli/card_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ func newCardGetCmd(cfg *globalConfig) *cobra.Command {
defer cancel()

if len(args) == 1 {
cfg.agentCard = args[0]
if err := cfg.agentCard.Set(args[0]); err != nil {
return err
}
}

var card *a2a.AgentCard
Expand Down Expand Up @@ -79,18 +81,17 @@ func getExtendedAgentCard(ctx context.Context, cfg *globalConfig) (*a2a.AgentCar
}

func getPublicAgentCard(ctx context.Context, cfg *globalConfig) (*a2a.AgentCard, error) {
ref := cfg.agentCard
if ref == "" {
if !cfg.agentCard.IsSet() {
return nil, fmt.Errorf("specify the agent card URL as an argument or with --agent-card")
}
ref := cfg.agentCard.URL()

var resolveOpts []agentcard.ResolveOption
if auth := cfg.svcParams.Auth(); auth != "" {
resolveOpts = append(resolveOpts, agentcard.WithRequestHeader("Authorization", auth))
}
cfg.logf("fetching agent card from %s", ref)

var err error
card, err := compatCardResolver.Resolve(ctx, ref, resolveOpts...)
if err != nil {
return nil, fmt.Errorf("failed to resolve agent card: %w", err)
Expand Down
74 changes: 68 additions & 6 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,20 @@ func TestCardGet(t *testing.T) {
t.Fatalf("card get <url> --extended ignored the positional url: %v", err)
}
})

agentCardFilePath := testutil.MustWriteTmpCardFile(t, newAgentCard(url, a2a.AgentCapabilities{
Streaming: true,
}))
t.Run("with positional file path", func(t *testing.T) {
out := mustRunCMD(t, "card", "get", agentCardFilePath, "-o", "json")
var card a2a.AgentCard
if err := json.Unmarshal([]byte(out), &card); err != nil {
t.Fatalf("json.Unmarshal(card get output) error = %v", err)
}
if card.Name != "Test Echo" {
t.Fatalf("a2a card get -a card.Name = %q, want %q", card.Name, "Test Echo")
}
})
}

func TestVersion(t *testing.T) {
Expand Down Expand Up @@ -239,6 +253,49 @@ func TestSend(t *testing.T) {
}
}

func TestSend_AgentCardFromFile(t *testing.T) {
t.Parallel()
url := startTestServer(t)
agentCardFilePath := testutil.MustWriteTmpCardFile(t, newAgentCard(url, a2a.AgentCapabilities{
Streaming: true,
}))

msgText := "hello hello!"
sendTests := []struct {
name string
args []string
wantText string
}{
{
name: "file path",
args: []string{"send", "-a", agentCardFilePath, "-o", "json", msgText},
wantText: msgText,
},
{
name: "file URL",
args: []string{"send", "-a", "file://" + agentCardFilePath, "-o", "json", msgText},
wantText: msgText,
},
}

for _, tt := range sendTests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
out, err := runCMD(t, tt.args...)
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 {
t.Fatalf("allArtifactText() = %q, want %q", text, tt.wantText)
}
})
}
}

func TestSendDataPart(t *testing.T) {
t.Parallel()
url := startTestServer(t)
Expand Down Expand Up @@ -601,16 +658,21 @@ func startTestServerWith(t *testing.T, capabilities a2a.AgentCapabilities) strin
server := httptest.NewServer(mux)
t.Cleanup(server.Close)

mux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(&a2a.AgentCard{
Name: "Test Echo",
Version: "1.0.0",
Capabilities: capabilities,
SupportedInterfaces: []*a2a.AgentInterface{a2a.NewAgentInterface(server.URL, a2a.TransportProtocolHTTPJSON)},
}))
agentCard := newAgentCard(server.URL, capabilities)
mux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(agentCard))

return server.URL
}

func newAgentCard(url string, cap a2a.AgentCapabilities) *a2a.AgentCard {
return &a2a.AgentCard{
Name: "Test Echo",
Version: "1.0.0",
Capabilities: cap,
SupportedInterfaces: []*a2a.AgentInterface{a2a.NewAgentInterface(url, a2a.TransportProtocolHTTPJSON)},
}
}

func startLegacyTestServer(t *testing.T) string {
t.Helper()

Expand Down
6 changes: 3 additions & 3 deletions internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ var compatCardResolver = func() *agentcard.Resolver {

func newAgentClient(ctx context.Context, cfg *globalConfig, extraOpts ...a2aclient.FactoryOption) (*a2aclient.Client, error) {
switch {
case cfg.url != "" && cfg.agentCard != "":
case cfg.url != "" && cfg.agentCard.IsSet():
return nil, fmt.Errorf("--endpoint and --agent-card are mutually exclusive")
case cfg.url != "":
return newClientFromEndpoint(ctx, cfg, cfg.url, extraOpts...)
case cfg.agentCard != "":
return newClientFromCard(ctx, cfg, cfg.agentCard, extraOpts...)
case cfg.agentCard.IsSet():
return newClientFromCard(ctx, cfg, cfg.agentCard.URL(), extraOpts...)
default:
return nil, fmt.Errorf("either '--agent-card <ref>' or '--endpoint <url> --transport <t>' must be provided")
}
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func (d *deps) setDefaults() {

type globalConfig struct {
output string
agentCard string
agentCard flagparse.URLOrPath
url string
transports []string
svcParams *flagparse.ServiceParams
Expand Down Expand Up @@ -113,7 +113,7 @@ func newRootCmd(cfg *globalConfig, deps deps) *cobra.Command {

pf := cmd.PersistentFlags()
pf.StringVarP(&cfg.output, "output", "o", "text", "Output format: text, json")
pf.StringVarP(&cfg.agentCard, "agent-card", "a", "", "Agent Card reference: host/origin, full card URL, or local file path")
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 (repeatable, highest preference first)")
cfg.svcParams.Attach(pf)
Expand Down
90 changes: 90 additions & 0 deletions internal/flagparse/urlorpath.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright 2026 The A2A Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package flagparse

import (
"net"
"os"
"path/filepath"
"strings"
)

// URLOrPath is a pflag.Value that accepts a reference in any of three forms — a
// bare host/origin, a full URL, or a local file path — and normalizes it to a
// URL with an explicit scheme.
type URLOrPath struct {
raw string
}

// Set records the raw flag value.
func (u *URLOrPath) Set(s string) error {
u.raw = s
return nil
}

// String returns the value exactly as provided on the command line.
func (u *URLOrPath) String() string { return u.raw }

// Type reports the pflag value type name.
func (u *URLOrPath) Type() string { return "host|url|path" }

// IsSet reports whether a non-empty value was provided.
func (u *URLOrPath) IsSet() bool { return u.raw != "" }

// URL returns the reference normalized to a URL with an explicit scheme.
func (u *URLOrPath) URL() string {
ref := u.raw
if ref == "" || strings.Contains(ref, "://") {
return ref
}
if maybeFilePath(ref) {
path := ref
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
return "file://" + path
}
if isLoopbackHost(ref) {
return "http://" + ref
}
return "https://" + ref
}

func maybeFilePath(ref string) bool {
for _, prefix := range []string{"/", "./", "../"} {
if strings.HasPrefix(ref, prefix) {
return true
}
}
if _, err := os.Stat(ref); err == nil {
return true
}
return false
}

func isLoopbackHost(ref string) bool {
host := ref
if h, _, err := net.SplitHostPort(ref); err == nil {
host = h
}
host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
if host == "localhost" {
return true
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return false
}
79 changes: 79 additions & 0 deletions internal/flagparse/urlorpath_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright 2026 The A2A Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package flagparse

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

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

cwd, err := os.Getwd()
if err != nil {
t.Fatalf("os.Getwd() error = %v", err)
}

tests := []struct {
name string
ref string
want string
}{
{name: "http url unchanged", ref: "http://a.example", want: "http://a.example"},
{name: "https url unchanged", ref: "https://a.example/card.json", want: "https://a.example/card.json"},
{name: "file url unchanged", ref: "file:///tmp/card.json", want: "file:///tmp/card.json"},
{name: "absolute path becomes file url", ref: "/tmp/card.json", want: "file:///tmp/card.json"},
{name: "bare host gets https", ref: "agent.example", want: "https://agent.example"},
{name: "bare host:port gets https", ref: "agent.example:8443", want: "https://agent.example:8443"},
{name: "loopback host:port gets http", ref: "127.0.0.1:8091", want: "http://127.0.0.1:8091"},
{name: "loopback range gets http", ref: "127.5.5.5", want: "http://127.5.5.5"},
{name: "ipv6 loopback gets http", ref: "[::1]:8080", want: "http://[::1]:8080"},
{name: "localhost gets http", ref: "localhost:9000", want: "http://localhost:9000"},
{name: "host starting with 127 is not loopback", ref: "127.example.com", want: "https://127.example.com"},
{name: "relative path", ref: "./card.json", want: "file://" + filepath.Join(cwd, "card.json")},
{name: "relative in parent", ref: "../card.json", want: "file://" + filepath.Join(cwd, "..", "card.json")},
{name: "empty stays empty", ref: "", want: ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var u URLOrPath
if err := u.Set(tt.ref); err != nil {
t.Fatalf("URLOrPath.Set(%q) error = %v", tt.ref, err)
}
if got := u.URL(); got != tt.want {
t.Fatalf("URLOrPath.URL() = %v, want %v", got, tt.want)
}
})
}
}

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

var u URLOrPath
if u.IsSet() {
t.Fatalf("URLOrPath.IsSet() = true, want false for the zero value")
}
if err := u.Set("agent.example"); err != nil {
t.Fatalf("URLOrPath.Set() error = %v", err)
}
if !u.IsSet() {
t.Fatalf("URLOrPath.IsSet() = false, want true after Set")
}
}
12 changes: 1 addition & 11 deletions internal/localsrv/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/google/go-cmp/cmp"
Expand Down Expand Up @@ -356,13 +354,5 @@ func mustWriteTmpCardFile(t *testing.T, params CardParams) string {
if err != nil {
t.Fatalf("createAgentCard(%v) error = %v", params, err)
}
cardBytes, err := json.Marshal(card)
if err != nil {
t.Fatalf("json.Marshal(card) error = %v", err)
}
path := filepath.Join(t.TempDir(), "card.json")
if err := os.WriteFile(path, cardBytes, os.ModePerm); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
return path
return testutil.MustWriteTmpCardFile(t, card)
}
Loading