diff --git a/go.mod b/go.mod index c80efb1..52fe9ad 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 8ccaabc..766d90f 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/internal/cli/card_get.go b/internal/cli/card_get.go index 6b02193..913b23b 100644 --- a/internal/cli/card_get.go +++ b/internal/cli/card_get.go @@ -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 @@ -79,10 +81,10 @@ 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 != "" { @@ -90,7 +92,6 @@ func getPublicAgentCard(ctx context.Context, cfg *globalConfig) (*a2a.AgentCard, } 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) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 122b5bb..cee4466 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -117,6 +117,20 @@ func TestCardGet(t *testing.T) { t.Fatalf("card get --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) { @@ -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) @@ -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() diff --git a/internal/cli/client.go b/internal/cli/client.go index 41df7b4..5de692c 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -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 ' or '--endpoint --transport ' must be provided") } diff --git a/internal/cli/root.go b/internal/cli/root.go index 5f74d56..196d3f8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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 @@ -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) diff --git a/internal/flagparse/urlorpath.go b/internal/flagparse/urlorpath.go new file mode 100644 index 0000000..e6a683c --- /dev/null +++ b/internal/flagparse/urlorpath.go @@ -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 +} diff --git a/internal/flagparse/urlorpath_test.go b/internal/flagparse/urlorpath_test.go new file mode 100644 index 0000000..9ce355c --- /dev/null +++ b/internal/flagparse/urlorpath_test.go @@ -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") + } +} diff --git a/internal/localsrv/serve_test.go b/internal/localsrv/serve_test.go index 3c9377e..edb559a 100644 --- a/internal/localsrv/serve_test.go +++ b/internal/localsrv/serve_test.go @@ -18,8 +18,6 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" "github.com/google/go-cmp/cmp" @@ -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) } diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 388708a..42ab3ff 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -16,7 +16,11 @@ package testutil import ( + "encoding/json" + "os" + "path/filepath" "strings" + "testing" "github.com/a2aproject/a2a-go/v2/a2a" ) @@ -31,3 +35,18 @@ func AllArtifactText(task *a2a.Task) string { } return sb.String() } + +// MustWriteTmpCardFile creates a card.json file in a temporary directory on the local +// file system and returns its path. +func MustWriteTmpCardFile(t *testing.T, card *a2a.AgentCard) string { + t.Helper() + 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 +}