From 6b5aa7ef9f712fb7824088c6146336d5ac4923bc Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Thu, 12 Nov 2026 14:45:48 +0000 Subject: [PATCH] feat: resolve --agent-card from AI Catalogs Embed ai-catalog-go to register AI Catalogs in the store shared with ai-catalog-cli and resolve --agent-card from the A2A agents they list, without depending on the ai-catalog binary. - a2a catalog add/list/update/remove/search (experimental) - -a looks the agent up across registered catalogs, or in the catalog given with --catalog - -a that serves a catalog resolves to its only A2A agent Fixes #19 --- README.md | 5 + go.mod | 1 + go.sum | 2 + internal/README.md | 40 +- internal/aicatalog/agents.go | 219 +++++++ internal/aicatalog/agents_test.go | 229 +++++++ internal/aicatalog/registry.go | 259 ++++++++ internal/aicatalog/store.go | 456 ++++++++++++++ internal/aicatalog/store_test.go | 559 ++++++++++++++++++ .../ai-catalog-cli-store/catalog.json | 23 + ...b79c6f73ac36e1e69b1e4d9df6e7549926c2a.json | 5 + ...8e97f4d3cd016323bdb5a9f2bf9bba5ab8c8f.json | 1 + .../testdata/ai-catalog-cli-store/refs.json | 4 + internal/aicatalog/walk.go | 246 ++++++++ internal/cli/agentref.go | 248 ++++++++ internal/cli/card_get.go | 7 +- internal/cli/catalog.go | 40 ++ internal/cli/catalog_add.go | 56 ++ internal/cli/catalog_list.go | 80 +++ internal/cli/catalog_remove.go | 45 ++ internal/cli/catalog_search.go | 104 ++++ internal/cli/catalog_test.go | 329 +++++++++++ internal/cli/catalog_update.go | 92 +++ internal/cli/client.go | 49 +- internal/cli/root.go | 9 +- skills/a2a-cli/SKILL.md | 8 +- 26 files changed, 3093 insertions(+), 23 deletions(-) create mode 100644 internal/aicatalog/agents.go create mode 100644 internal/aicatalog/agents_test.go create mode 100644 internal/aicatalog/registry.go create mode 100644 internal/aicatalog/store.go create mode 100644 internal/aicatalog/store_test.go create mode 100644 internal/aicatalog/testdata/ai-catalog-cli-store/catalog.json create mode 100644 internal/aicatalog/testdata/ai-catalog-cli-store/objects/1ca21b2f25e1e7ffdbe3d7628b1b79c6f73ac36e1e69b1e4d9df6e7549926c2a.json create mode 100644 internal/aicatalog/testdata/ai-catalog-cli-store/objects/b4d67cbbb90a20cea8a8b0246a48e97f4d3cd016323bdb5a9f2bf9bba5ab8c8f.json create mode 100644 internal/aicatalog/testdata/ai-catalog-cli-store/refs.json create mode 100644 internal/aicatalog/walk.go create mode 100644 internal/cli/agentref.go create mode 100644 internal/cli/catalog.go create mode 100644 internal/cli/catalog_add.go create mode 100644 internal/cli/catalog_list.go create mode 100644 internal/cli/catalog_remove.go create mode 100644 internal/cli/catalog_search.go create mode 100644 internal/cli/catalog_test.go create mode 100644 internal/cli/catalog_update.go diff --git a/README.md b/README.md index 8fa586b..06df40b 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,11 @@ a2a send -a https://agent.example.com --stream "Summarize this document" # Send asynchronously and poll until completion a2a send -a https://agent.example.com --async "Analyze this dataset" a2a task get -a https://agent.example.com --wait --timeout 60s + +# Find agents in an AI Catalog and talk to one by its identifier (experimental) +a2a catalog add corp https://acme.com +a2a catalog search finance +a2a send -a urn:air:acme.com:agent:finance "What were last quarter's expenses?" ``` See the **[full command reference](./internal/README.md)** for all commands, flags, configuration, and server mode. diff --git a/go.mod b/go.mod index 4973ff2..387db28 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/a2aproject/a2a-cli go 1.26.0 require ( + github.com/Agent-Card/ai-catalog-go v0.3.0 github.com/a2aproject/a2a-go v0.3.15 github.com/a2aproject/a2a-go/v2 v2.8.0 github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum index 23ff8f6..881db90 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Agent-Card/ai-catalog-go v0.3.0 h1:DP49MyGN4WwdNLK4iClq0US8W3bnfDbaN4KBhOmqrUY= +github.com/Agent-Card/ai-catalog-go v0.3.0/go.mod h1:xMelpW5dWl60Fb6sRasAAvZdTMdbfDdPvncez+VwA9E= 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.8.0 h1:RmZBANuY8P9Pm1dfZ8gCY2yHAuVO7OnTFw8/kOZdCQQ= diff --git a/internal/README.md b/internal/README.md index 85029bc..925d099 100644 --- a/internal/README.md +++ b/internal/README.md @@ -31,7 +31,8 @@ These apply to every client-mode command. Each command selects the agent it talk | Flag | Short | Description | |---|---|---| -| `--agent-card ` | `-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. | +| `--agent-card ` | `-a` | Agent Card reference: a host/origin (the well-known path is appended), a full card URL, a local file path, or the identifier of an agent in an [AI Catalog](#ai-catalogs-experimental). The card is resolved and a transport negotiated. | +| `--catalog ` | | AI Catalog to look the `--agent-card` identifier up in: a registered catalog name, or a catalog URL or path. Experimental. | | `--endpoint ` | `-e` | Agent interface URL for a direct connection, skipping card resolution. Must be paired with exactly one `--transport`. Mutually exclusive with `--agent-card`. | | `--transport ` | | 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. | | `--insecure` | | Use plaintext for the built-in gRPC client. By default it uses TLS and verifies the server certificate against the system trust store, or the certificates in `--ca-cert`. | @@ -156,7 +157,7 @@ To fetch the extended card (if supported): a2a card get --extended --auth "Bearer " ``` -The card reference may also be supplied via the global `-a, --agent-card` flag instead of the positional argument. +The card reference may also be supplied via the global `-a, --agent-card` flag instead of the positional argument. Like `--agent-card`, it may be the identifier of an agent in an [AI Catalog](#ai-catalogs-experimental). ### `send` - Send a Message @@ -297,6 +298,41 @@ Streams events to stdout until the task reaches a terminal state. Output format --- +## AI Catalogs (experimental) + +An [AI Catalog](https://ai-catalog.io/) lists AI artifacts, A2A agents among them. `a2a` can register catalogs and resolve `--agent-card` from the agents they list, so you can refer to an agent by its catalog identifier instead of a card URL. + +Registered catalogs live in the store shared with [ai-catalog-cli](https://github.com/Agent-Card/ai-catalog-cli): `~/.ai-catalog`, or the directory in `AI_CATALOG_CACHE_DIR`. A catalog registered with either tool is visible to both. The store layout is not part of the AI Catalog specification, so these commands are experimental, and `a2a` warns about and refuses to modify a store whose format it does not recognize. + +```bash +a2a catalog add corp https://acme.com # fetches https://acme.com/.well-known/ai-catalog.json +a2a catalog add local ./ai-catalog.json +a2a catalog list +a2a catalog search finance # A2A agents whose identifier, name, description or tags match +a2a catalog search --catalog https://acme.com/ai-catalog.json # search a catalog without registering it +a2a catalog update [name] # fetch one or every registered catalog again +a2a catalog remove corp +``` + +`search` lists only A2A agents (entries of type `application/a2a-agent-card+json`). Catalogs nested in a catalog are followed up to four levels deep: any catalog may reference `https` URLs, and only a local catalog may reference local files. + +Pass an agent's identifier to `--agent-card`: + +```bash +a2a send -a urn:air:acme.com:agent:finance "..." # across all registered catalogs +a2a send --catalog corp -a urn:air:acme.com:agent:finance "..." # in a registered catalog +a2a send --catalog https://acme.com/ai-catalog.json -a urn:air:acme.com:agent:finance "..." # in an unregistered catalog +a2a send -a https://acme.com/finance-catalog.json "..." # a catalog with a single A2A agent +``` + +- A `urn:` value of `--agent-card` is an identifier, unless a file with that name exists. With `--catalog`, any value is an identifier. +- Lookups in registered catalogs read only the local store; run `a2a catalog update` to pick up changes. +- An identifier listed by more than one catalog is an error; pick one with `--catalog`. +- When a catalog lists several versions of an agent, the latest is used. +- A URL passed to `--agent-card` that serves a catalog instead of a card resolves only when the catalog lists exactly one A2A agent; otherwise the error lists the agents. + +--- + ## Credentials A credential helper is a program that returns the HTTP headers to send to an agent. a2a implements the [Bazel credential helper protocol](https://github.com/EngFlow/credential-helper-spec), so helpers written for Bazel work unchanged: a2a runs ` get`, writes `{"uri": ""}` to its stdin, and reads `{"headers": {"Authorization": ["Bearer …"]}, "expires": ""}` from its stdout. diff --git a/internal/aicatalog/agents.go b/internal/aicatalog/agents.go new file mode 100644 index 0000000..c3b1898 --- /dev/null +++ b/internal/aicatalog/agents.go @@ -0,0 +1,219 @@ +// 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 aicatalog + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/Agent-Card/ai-catalog-go/catalog" +) + +// IsIdentifier reports whether an --agent-card value names a catalog entry +// rather than a host, URL or path: it is a URN and not an existing file. +func IsIdentifier(ref string) bool { + if len(ref) <= len("urn:") || !strings.EqualFold(ref[:len("urn:")], "urn:") { + return false + } + _, err := os.Stat(ref) + return err != nil +} + +// NormalizeSource turns a catalog URL, local path or bare host into the URL it +// is fetched from. A URL without a path and a bare host get the well-known path. +func NormalizeSource(source string) (string, error) { + source = strings.TrimSpace(source) + if source == "" { + return "", errors.New("catalog source must not be empty") + } + if !strings.Contains(source, "://") { + if _, err := os.Stat(source); err == nil { + abs, err := filepath.Abs(source) + if err != nil { + return "", err + } + return (&url.URL{Scheme: "file", Path: filepath.ToSlash(abs)}).String(), nil + } + source = "https://" + source + } + u, err := url.Parse(source) + if err != nil { + return "", fmt.Errorf("invalid catalog source %q: %w", source, err) + } + switch u.Scheme { + case "file": + return u.String(), nil + case "http", "https": + default: + return "", fmt.Errorf("unsupported catalog source %q: want an http, https or file URL, or a local path", source) + } + if u.Host == "" { + return "", fmt.Errorf("invalid catalog source %q: missing host", source) + } + if u.Path == "" || u.Path == "/" { + u.Path = catalog.WellKnownPath + } + return u.String(), nil +} + +// IsAgent reports whether the entry is an A2A Agent Card. +func IsAgent(e *catalog.CatalogEntry) bool { + return e.Type == catalog.MediaTypeA2AAgentCard +} + +// Agents returns the A2A agents among entries, each listed once per catalog. +func Agents(entries []Entry) []Entry { + type key struct{ catalog, id, version string } + seen := map[key]bool{} + var agents []Entry + for _, e := range entries { + k := key{e.Catalog, e.Identifier, e.Version} + if !IsAgent(&e.CatalogEntry) || seen[k] { + continue + } + seen[k] = true + agents = append(agents, e) + } + return agents +} + +// Search returns the A2A agents whose identifier, display name, description or +// tags contain query, ignoring case. An empty query matches every agent. +func Search(entries []Entry, query string) []Entry { + query = strings.ToLower(query) + var found []Entry + for _, e := range Agents(entries) { + fields := append([]string{e.Identifier, e.DisplayName, e.Description}, e.Tags...) + if slices.ContainsFunc(fields, func(f string) bool { return strings.Contains(strings.ToLower(f), query) }) { + found = append(found, e) + } + } + return found +} + +// NotFoundError reports an identifier that names no A2A agent in Scope. +type NotFoundError struct { + Identifier string + Scope string + // Type is the media type of an entry with the identifier that is not an A2A agent. + Type string + // Agents are the A2A agents that are in Scope. + Agents []Entry +} + +func (e *NotFoundError) Error() string { + if e.Type != "" { + return fmt.Sprintf("%q in %s is a %s entry, not an A2A agent", e.Identifier, e.Scope, e.Type) + } + return fmt.Sprintf("no A2A agent %q in %s", e.Identifier, e.Scope) +} + +// AmbiguousError reports an identifier listed by more than one catalog. +type AmbiguousError struct { + Identifier string + Catalogs []string +} + +func (e *AmbiguousError) Error() string { + return fmt.Sprintf("A2A agent %q is listed by more than one catalog: %s", e.Identifier, strings.Join(e.Catalogs, ", ")) +} + +// NoSingleAgentError reports a catalog used as an --agent-card that does not +// list exactly one A2A agent. +type NoSingleAgentError struct { + Source string + Agents []Entry +} + +func (e *NoSingleAgentError) Error() string { + if len(e.Agents) == 0 { + return fmt.Sprintf("%s is an AI Catalog with no A2A agents", e.Source) + } + return fmt.Sprintf("%s is an AI Catalog with %d A2A agents; pick one with --catalog %s -a ", e.Source, len(e.Agents), e.Source) +} + +// FindAgent returns the A2A agent with the identifier id among entries, which +// were collected from scope. When a catalog lists several versions, the +// latest is returned. +func FindAgent(entries []Entry, id, scope string) (Entry, error) { + var matches []Entry + var catalogs []string + otherType := "" + for _, e := range entries { + if e.Identifier != id { + continue + } + if !IsAgent(&e.CatalogEntry) { + otherType = e.Type + continue + } + matches = append(matches, e) + if !slices.Contains(catalogs, e.Catalog) { + catalogs = append(catalogs, e.Catalog) + } + } + switch { + case len(matches) == 0: + return Entry{}, &NotFoundError{Identifier: id, Scope: scope, Type: otherType, Agents: Agents(entries)} + case len(catalogs) > 1: + return Entry{}, &AmbiguousError{Identifier: id, Catalogs: catalogs} + } + doc := catalog.AICatalog{} + for _, m := range matches { + doc.Entries = append(doc.Entries, m.CatalogEntry) + } + latest, _ := doc.GetLatest(id) + for i := range doc.Entries { + if &doc.Entries[i] == latest { + return matches[i], nil + } + } + return matches[0], nil +} + +// SingleAgent returns the only A2A agent among the entries of the catalog at source. +func SingleAgent(entries []Entry, source string) (Entry, error) { + agents := Agents(entries) + if len(agents) != 1 { + return Entry{}, &NoSingleAgentError{Source: source, Agents: agents} + } + return agents[0], nil +} + +// CardURL returns the URL of the Agent Card an entry points to. +func CardURL(e Entry) (string, error) { + if e.URL == "" { + return "", fmt.Errorf("entry %q has neither data nor a URL", e.Identifier) + } + return resolveReference(e.Base, e.URL) +} + +// IsCatalogDocument reports whether data is an AI Catalog rather than an Agent Card. +func IsCatalogDocument(data []byte) bool { + var doc struct { + SpecVersion *string `json:"specVersion"` + Entries *json.RawMessage `json:"entries"` + } + if err := json.Unmarshal(data, &doc); err != nil { + return false + } + return doc.SpecVersion != nil && doc.Entries != nil +} diff --git a/internal/aicatalog/agents_test.go b/internal/aicatalog/agents_test.go new file mode 100644 index 0000000..5c1f552 --- /dev/null +++ b/internal/aicatalog/agents_test.go @@ -0,0 +1,229 @@ +// 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 aicatalog + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Card/ai-catalog-go/catalog" + "github.com/google/go-cmp/cmp" +) + +func agentEntry(cat, id, version string) Entry { + return Entry{ + CatalogEntry: catalog.CatalogEntry{Identifier: id, Version: version, Type: catalog.MediaTypeA2AAgentCard, URL: "https://x.example/" + id}, + Catalog: cat, + } +} + +func TestIsIdentifier(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("os.Getwd() error = %v", err) + } + file, err := filepath.Rel(cwd, filepath.Join(dir, "urn:file.json")) + if err != nil { + t.Fatalf("filepath.Rel() error = %v", err) + } + if err := os.WriteFile(file, []byte("{}"), 0o600); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + tests := []struct { + ref string + want bool + }{ + {ref: "urn:air:acme.com:agent:finance", want: true}, + {ref: "URN:air:acme.com:agent:finance", want: true}, + {ref: "urn:", want: false}, + {ref: "https://acme.com", want: false}, + {ref: "acme.com", want: false}, + {ref: "./card.json", want: false}, + {ref: file, want: false}, + } + for _, tt := range tests { + if got := IsIdentifier(tt.ref); got != tt.want { + t.Fatalf("IsIdentifier(%q) = %v, want %v", tt.ref, got, tt.want) + } + } +} + +func TestNormalizeSource(t *testing.T) { + t.Parallel() + path := writeFile(t, "cat.json", "{}") + + tests := []struct { + source string + want string + wantErr bool + }{ + {source: "acme.com", want: "https://acme.com/.well-known/ai-catalog.json"}, + {source: "https://acme.com/", want: "https://acme.com/.well-known/ai-catalog.json"}, + {source: "https://acme.com/catalogs/finance.json", want: "https://acme.com/catalogs/finance.json"}, + {source: "http://localhost:8080", want: "http://localhost:8080/.well-known/ai-catalog.json"}, + {source: path, want: "file://" + filepath.ToSlash(path)}, + {source: "file:///tmp/c.json", want: "file:///tmp/c.json"}, + {source: "", wantErr: true}, + {source: "https://", wantErr: true}, + {source: "ftp://acme.com/c.json", wantErr: true}, + } + for _, tt := range tests { + got, err := NormalizeSource(tt.source) + if tt.wantErr != (err != nil) { + t.Fatalf("NormalizeSource(%q) error = %v, want error = %v", tt.source, err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("NormalizeSource(%q) = %v, want %v", tt.source, got, tt.want) + } + } +} + +func TestFindAgent(t *testing.T) { + t.Parallel() + mcp := Entry{CatalogEntry: catalog.CatalogEntry{Identifier: "urn:mcp", Type: catalog.MediaTypeMCPServerCard}, Catalog: "corp"} + entries := []Entry{ + agentEntry("corp", "urn:finance", "1.2.0"), + agentEntry("corp", "urn:finance", "1.10.0"), + agentEntry("corp", "urn:finance", "1.9.0"), + agentEntry("corp", "urn:shared", ""), + agentEntry("lab", "urn:shared", ""), + mcp, + } + + t.Run("picks the latest version", func(t *testing.T) { + t.Parallel() + got, err := FindAgent(entries, "urn:finance", "scope") + if err != nil { + t.Fatalf("FindAgent() error = %v", err) + } + if got.Version != "1.10.0" { + t.Fatalf("FindAgent() Version = %v, want %v", got.Version, "1.10.0") + } + }) + + t.Run("listed by two catalogs", func(t *testing.T) { + t.Parallel() + _, err := FindAgent(entries, "urn:shared", "scope") + var ambiguous *AmbiguousError + if !errors.As(err, &ambiguous) { + t.Fatalf("FindAgent() error = %v, want *AmbiguousError", err) + } + if diff := cmp.Diff([]string{"corp", "lab"}, ambiguous.Catalogs); diff != "" { + t.Fatalf("FindAgent() Catalogs wrong result (-want +got) diff = %s", diff) + } + }) + + t.Run("not an agent", func(t *testing.T) { + t.Parallel() + _, err := FindAgent(entries, "urn:mcp", "scope") + var notFound *NotFoundError + if !errors.As(err, ¬Found) || notFound.Type != catalog.MediaTypeMCPServerCard { + t.Fatalf("FindAgent() error = %v, want *NotFoundError with the entry type", err) + } + }) + + t.Run("missing lists the agents in scope", func(t *testing.T) { + t.Parallel() + _, err := FindAgent(entries, "urn:nope", "scope") + var notFound *NotFoundError + if !errors.As(err, ¬Found) { + t.Fatalf("FindAgent() error = %v, want *NotFoundError", err) + } + if diff := cmp.Diff([]string{"corp urn:finance", "corp urn:finance", "corp urn:finance", "corp urn:shared", "lab urn:shared"}, identifiers(notFound.Agents)); diff != "" { + t.Fatalf("FindAgent() Agents wrong result (-want +got) diff = %s", diff) + } + }) +} + +func TestSingleAgent(t *testing.T) { + t.Parallel() + mcp := Entry{CatalogEntry: catalog.CatalogEntry{Identifier: "urn:mcp", Type: catalog.MediaTypeMCPServerCard}} + + tests := []struct { + name string + entries []Entry + wantID string + wantAgents int + }{ + {name: "one agent among other entries", entries: []Entry{mcp, agentEntry("c", "urn:a", "")}, wantID: "urn:a"}, + {name: "same agent listed twice", entries: []Entry{agentEntry("c", "urn:a", "1"), agentEntry("c", "urn:a", "1")}, wantID: "urn:a"}, + {name: "no agents", entries: []Entry{mcp}, wantAgents: 0}, + {name: "two agents", entries: []Entry{agentEntry("c", "urn:a", ""), agentEntry("c", "urn:b", "")}, wantAgents: 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := SingleAgent(tt.entries, "src") + if tt.wantID != "" { + if err != nil || got.Identifier != tt.wantID { + t.Fatalf("SingleAgent() = %v, %v, want %v", got.Identifier, err, tt.wantID) + } + return + } + var noSingle *NoSingleAgentError + if !errors.As(err, &noSingle) || len(noSingle.Agents) != tt.wantAgents { + t.Fatalf("SingleAgent() error = %v, want *NoSingleAgentError with %d agents", err, tt.wantAgents) + } + }) + } +} + +func TestSearch(t *testing.T) { + t.Parallel() + finance := agentEntry("corp", "urn:air:acme.com:agent:finance", "") + finance.Tags = []string{"Money"} + hr := agentEntry("corp", "urn:air:acme.com:agent:hr", "") + hr.Description = "Hiring and payroll" + mcp := Entry{CatalogEntry: catalog.CatalogEntry{Identifier: "urn:air:acme.com:mcp:money", Type: catalog.MediaTypeMCPServerCard}} + entries := []Entry{finance, hr, mcp} + + tests := []struct { + query string + want []string + }{ + {query: "", want: []string{"corp urn:air:acme.com:agent:finance", "corp urn:air:acme.com:agent:hr"}}, + {query: "money", want: []string{"corp urn:air:acme.com:agent:finance"}}, + {query: "PAYROLL", want: []string{"corp urn:air:acme.com:agent:hr"}}, + {query: "nothing", want: []string{}}, + } + for _, tt := range tests { + if diff := cmp.Diff(tt.want, identifiers(Search(entries, tt.query))); diff != "" { + t.Fatalf("Search(%q) wrong result (-want +got) diff = %s", tt.query, diff) + } + } +} + +func TestIsCatalogDocument(t *testing.T) { + t.Parallel() + tests := []struct { + doc string + want bool + }{ + {doc: `{"specVersion":"1.0","entries":[]}`, want: true}, + {doc: `{"name":"agent","supportedInterfaces":[]}`, want: false}, + {doc: `{"specVersion":"1.0"}`, want: false}, + {doc: `not json`, want: false}, + } + for _, tt := range tests { + if got := IsCatalogDocument([]byte(tt.doc)); got != tt.want { + t.Fatalf("IsCatalogDocument(%s) = %v, want %v", tt.doc, got, tt.want) + } + } +} diff --git a/internal/aicatalog/registry.go b/internal/aicatalog/registry.go new file mode 100644 index 0000000..50b5e97 --- /dev/null +++ b/internal/aicatalog/registry.go @@ -0,0 +1,259 @@ +// 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 aicatalog + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "strings" + "time" + + "github.com/Agent-Card/ai-catalog-go/catalog" +) + +// Result is the outcome of fetching a catalog. +type Result struct { + Catalog Catalog + // Changed reports whether an update stored new content. + Changed bool + // Warnings describe nested catalogs that were skipped. + Warnings []string +} + +// Add fetches the catalog at source, a URL or local path, and registers it as name. +func (r *Registry) Add(ctx context.Context, f *Fetcher, name, source string) (*Result, error) { + if err := r.writable(); err != nil { + return nil, err + } + if strings.TrimSpace(name) == "" { + return nil, errors.New("catalog name must not be empty") + } + sourceURL, err := NormalizeSource(source) + if err != nil { + return nil, err + } + if existing := r.find(name); existing != nil { + return nil, fmt.Errorf("a catalog named %q is already registered", existing.Name) + } + if existing := r.find(sourceURL); existing != nil { + return nil, fmt.Errorf("%s is already registered as %q", sourceURL, existing.Name) + } + + res, err := r.fetch(ctx, f, sourceURL) + if err != nil { + return nil, err + } + res.Catalog.Name = name + entry := ®istryEntry{raw: map[string]json.RawMessage{ + "identifier": mustMarshal(registryIdentifier(name)), + "displayName": mustMarshal(name), + "type": mustMarshal(catalog.MediaTypeCatalog), + }} + if err := entry.set(res.Catalog, r.store.objectURL(res.Catalog.ContentHash)); err != nil { + return nil, err + } + r.entries = append(r.entries, entry) + if err := r.save(); err != nil { + return nil, err + } + res.Changed = true + return res, nil +} + +// Update fetches a registered catalog from its source again. +func (r *Registry) Update(ctx context.Context, f *Fetcher, name string) (*Result, error) { + if err := r.writable(); err != nil { + return nil, err + } + entry := r.find(name) + if entry == nil { + return nil, &UnknownCatalogError{Name: name} + } + if entry.SourceType == "oci" { + return nil, fmt.Errorf("catalog %q was imported from an OCI image layout; update it with ai-catalog", entry.Name) + } + res, err := r.fetch(ctx, f, entry.Source) + if err != nil { + return nil, err + } + res.Catalog.Name = entry.Name + if res.Catalog.ContentHash == entry.ContentHash { + res.Catalog = entry.Catalog + return res, nil + } + if err := entry.set(res.Catalog, r.store.objectURL(res.Catalog.ContentHash)); err != nil { + return nil, err + } + if err := r.save(); err != nil { + return nil, err + } + res.Changed = true + return res, nil +} + +// Remove unregisters a catalog by name or source. Its stored document is +// deleted unless another registered catalog still uses it, directly or as a +// nested catalog. +func (r *Registry) Remove(ctx context.Context, nameOrSource string) (Catalog, error) { + if err := r.writable(); err != nil { + return Catalog{}, err + } + entry := r.find(nameOrSource) + if entry == nil { + return Catalog{}, &UnknownCatalogError{Name: nameOrSource} + } + r.entries = slices.DeleteFunc(r.entries, func(e *registryEntry) bool { return e == entry }) + if err := r.save(); err != nil { + return Catalog{}, err + } + if slices.Contains(r.reachableObjects(ctx), entry.ContentHash) { + return entry.Catalog, nil + } + if err := r.store.removeObject(entry.ContentHash); err != nil { + return Catalog{}, err + } + maps.DeleteFunc(r.refs, func(_, hash string) bool { return hash == entry.ContentHash }) + if err := r.saveRefs(); err != nil { + return Catalog{}, err + } + return entry.Catalog, nil +} + +// reachableObjects returns the hashes of the documents the registered catalogs +// use, nested catalogs included. +func (r *Registry) reachableObjects(ctx context.Context) []string { + var hashes []string + for _, e := range r.entries { + hashes = append(hashes, e.ContentHash) + } + entries, _, err := r.Entries(ctx, "") + if err != nil { + return hashes + } + for _, e := range entries { + if !e.IsNestedCatalog() || e.URL == "" { + continue + } + if ref, err := resolveReference(e.Base, e.URL); err == nil { + if hash, ok := r.refs[ref]; ok { + hashes = append(hashes, hash) + } + } + } + return hashes +} + +// fetch stores the catalog at sourceURL and every catalog nested in it. +func (r *Registry) fetch(ctx context.Context, f *Fetcher, sourceURL string) (*Result, error) { + data, err := f.Fetch(ctx, sourceURL) + if err != nil { + return nil, err + } + doc, err := catalog.Parse(data) + if err != nil { + return nil, fmt.Errorf("%s is not an AI Catalog: %w", sourceURL, err) + } + hash, err := r.store.writeObject(data) + if err != nil { + return nil, err + } + r.refs[sourceURL] = hash + + w := &walker{store: r.store, refs: r.refs, fetcher: f, record: true, label: sourceURL, path: []string{sourceURL}} + w.walk(ctx, doc, sourceURL, 0) + if err := r.saveRefs(); err != nil { + return nil, err + } + return &Result{ + Catalog: Catalog{ + Source: sourceURL, + ContentHash: hash, + EntryCount: countLeaves(w.entries), + LastUpdated: time.Now().UTC().Format(time.RFC3339), + }, + Warnings: w.warnings, + }, nil +} + +// Entries returns the entries of the registered catalog with the name or source +// nameOrSource, or of every registered catalog when it is empty, nested +// catalogs included. It reads only the local store. +func (r *Registry) Entries(ctx context.Context, nameOrSource string) ([]Entry, []string, error) { + targets := r.entries + if nameOrSource != "" { + entry := r.find(nameOrSource) + if entry == nil { + return nil, nil, &UnknownCatalogError{Name: nameOrSource} + } + targets = []*registryEntry{entry} + } + var entries []Entry + var warnings []string + for _, e := range targets { + if e.err != nil { + continue + } + data, err := r.store.readObject(e.ContentHash) + if err != nil { + warnings = append(warnings, fmt.Sprintf("skipping catalog %q: %v", e.Name, err)) + continue + } + doc, err := catalog.Parse(data) + if err != nil { + warnings = append(warnings, fmt.Sprintf("skipping catalog %q: %v", e.Name, err)) + continue + } + w := &walker{store: r.store, refs: r.refs, label: e.Name, path: []string{e.Source}} + w.walk(ctx, doc, e.Source, 0) + entries = append(entries, w.entries...) + warnings = append(warnings, w.warnings...) + } + return entries, warnings, nil +} + +// Load fetches the catalog document data was served from at sourceURL, and +// the catalogs nested in it, without storing anything. +func Load(ctx context.Context, f *Fetcher, sourceURL string, data []byte) ([]Entry, []string, error) { + doc, err := catalog.Parse(data) + if err != nil { + return nil, nil, fmt.Errorf("%s is not an AI Catalog: %w", sourceURL, err) + } + w := &walker{fetcher: f, label: sourceURL, path: []string{sourceURL}} + w.walk(ctx, doc, sourceURL, 0) + return w.entries, w.warnings, nil +} + +// registryIdentifier derives a registry entry identifier from the catalog name +// the same way ai-catalog-cli does, so it stays stable across updates. +func registryIdentifier(name string) string { + sum := sha256.Sum256([]byte(strings.ToLower(name))) + return "urn:ai-catalog:local:" + hex.EncodeToString(sum[:])[:8] +} + +func countLeaves(entries []Entry) int { + n := 0 + for _, e := range entries { + if !e.IsNestedCatalog() { + n++ + } + } + return n +} diff --git a/internal/aicatalog/store.go b/internal/aicatalog/store.go new file mode 100644 index 0000000..4d849eb --- /dev/null +++ b/internal/aicatalog/store.go @@ -0,0 +1,456 @@ +// 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 aicatalog keeps registered AI Catalogs in the local store shared with +// ai-catalog-cli, and finds the A2A agents those catalogs list. +// +// The store layout is not part of the AI Catalog specification. This package +// follows ai-catalog-cli: catalog.json is an AI Catalog whose entries are the +// registered catalogs, refs.json maps each fetched URL to the SHA-256 of its +// content, and objects/ holds every fetched document named by that hash. +package aicatalog + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Card/ai-catalog-go/catalog" +) + +// EnvDir names the environment variable that overrides the store directory. +const EnvDir = "AI_CATALOG_CACHE_DIR" + +const ( + registryFile = "catalog.json" + refsFile = "refs.json" + objectsDir = "objects" + + // extensionKey namespaces the bookkeeping ai-catalog-cli keeps on a registry entry. + extensionKey = "org.ai-catalog.ai-catalog-cli" + + registrySpecVersion = "1.0" + registryHostName = "ai-catalog local registry" +) + +// DefaultDir returns the store directory: $AI_CATALOG_CACHE_DIR, or ~/.ai-catalog. +func DefaultDir() (string, error) { + if dir := os.Getenv(EnvDir); dir != "" { + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolving home directory: %w", err) + } + return filepath.Join(home, ".ai-catalog"), nil +} + +// Store is a local catalog store rooted at Dir. +type Store struct { + Dir string +} + +// Catalog is a registered catalog. +type Catalog struct { + // Name is the name the catalog was registered under, unique ignoring case. + Name string + // Source is the URL the catalog was fetched from. + Source string + // ContentHash is the SHA-256 of the stored document. + ContentHash string + // EntryCount is the number of entries that are not catalogs, nested ones included. + EntryCount int + // LastUpdated is when the catalog was last fetched, in RFC 3339. + LastUpdated string + // SourceType is "oci" for a catalog imported from an OCI image layout. + SourceType string +} + +// UnknownCatalogError reports a name or source that matches no registered catalog. +type UnknownCatalogError struct { + Name string +} + +func (e *UnknownCatalogError) Error() string { + return fmt.Sprintf("no registered catalog matches %q", e.Name) +} + +// UnsupportedFormatError reports a store this package does not recognize and +// therefore does not modify. +type UnsupportedFormatError struct { + Dir string + Err error +} + +func (e *UnsupportedFormatError) Error() string { + return fmt.Sprintf("unrecognized AI Catalog store format in %s: %v", e.Dir, e.Err) +} + +func (e *UnsupportedFormatError) Unwrap() error { return e.Err } + +// Registry is the content of a store, read by [Store.Open]. +type Registry struct { + store *Store + members map[string]json.RawMessage + entries []*registryEntry + refs map[string]string + + // Problem is an [*UnsupportedFormatError] when the store is not in a format + // this package recognizes. Such a store is read on a best-effort basis and + // every method that would modify it fails. + Problem error +} + +type registryEntry struct { + Catalog + raw map[string]json.RawMessage + ext map[string]json.RawMessage + err error +} + +type bookkeeping struct { + SourceURL string `json:"sourceUrl"` + ContentHash string `json:"contentHash"` + EntryCount int `json:"entryCount"` + LastUpdated string `json:"lastUpdated"` + SourceType string `json:"sourceType"` +} + +// Open reads the registry. A missing store reads as an empty one. +func (s *Store) Open() (*Registry, error) { + r := &Registry{store: s, refs: map[string]string{}} + var problems []error + + data, err := os.ReadFile(s.path(registryFile)) + switch { + case errors.Is(err, fs.ErrNotExist): + r.members = newRegistryMembers() + case err != nil: + return nil, fmt.Errorf("reading catalog registry: %w", err) + default: + if err := r.parseRegistry(data); err != nil { + problems = append(problems, fmt.Errorf("%s: %w", registryFile, err)) + } + } + for _, e := range r.entries { + if e.err != nil { + problems = append(problems, fmt.Errorf("%s: entry %q: %w", registryFile, e.Name, e.err)) + } + } + + data, err = os.ReadFile(s.path(refsFile)) + switch { + case errors.Is(err, fs.ErrNotExist): + case err != nil: + return nil, fmt.Errorf("reading catalog refs: %w", err) + default: + if err := json.Unmarshal(data, &r.refs); err != nil { + r.refs = map[string]string{} + problems = append(problems, fmt.Errorf("%s: %w", refsFile, err)) + } + } + + if len(problems) > 0 { + r.Problem = &UnsupportedFormatError{Dir: s.Dir, Err: errors.Join(problems...)} + } + return r, nil +} + +func newRegistryMembers() map[string]json.RawMessage { + return map[string]json.RawMessage{ + "specVersion": mustMarshal(registrySpecVersion), + "host": mustMarshal(catalog.HostInfo{DisplayName: registryHostName}), + "entries": json.RawMessage("[]"), + } +} + +func (r *Registry) parseRegistry(data []byte) error { + if err := json.Unmarshal(data, &r.members); err != nil { + return err + } + if r.members == nil { + return errors.New("not a JSON object") + } + var version string + if err := json.Unmarshal(r.members["specVersion"], &version); err != nil { + return fmt.Errorf("specVersion: %w", err) + } + if major, _, _ := strings.Cut(version, "."); major != "1" { + return fmt.Errorf("unsupported specVersion %q", version) + } + var raws []map[string]json.RawMessage + if err := json.Unmarshal(r.members["entries"], &raws); err != nil { + return fmt.Errorf("entries: %w", err) + } + for _, raw := range raws { + r.entries = append(r.entries, parseRegistryEntry(raw)) + } + return nil +} + +func parseRegistryEntry(raw map[string]json.RawMessage) *registryEntry { + e := ®istryEntry{raw: raw} + data, err := json.Marshal(raw) + if err != nil { + e.err = err + return e + } + var parsed catalog.CatalogEntry + if err := json.Unmarshal(data, &parsed); err != nil { + e.err = err + return e + } + e.Name = parsed.DisplayName + if e.Name == "" { + e.Name = parsed.Identifier + } + if parsed.Type != catalog.MediaTypeCatalog { + e.err = fmt.Errorf("type %q is not %q", parsed.Type, catalog.MediaTypeCatalog) + return e + } + extData, ok := parsed.Extensions[extensionKey] + if !ok { + e.err = fmt.Errorf("missing %q extension", extensionKey) + return e + } + var b bookkeeping + if err := json.Unmarshal(extData, &e.ext); err != nil { + e.err = fmt.Errorf("%q extension: %w", extensionKey, err) + return e + } + if err := json.Unmarshal(extData, &b); err != nil { + e.err = fmt.Errorf("%q extension: %w", extensionKey, err) + return e + } + e.Source = b.SourceURL + e.ContentHash = b.ContentHash + e.EntryCount = b.EntryCount + e.LastUpdated = b.LastUpdated + e.SourceType = b.SourceType + switch { + case parsed.DisplayName == "": + e.err = errors.New("missing displayName") + case e.Source == "": + e.err = errors.New("missing sourceUrl") + case !isContentHash(e.ContentHash): + e.err = fmt.Errorf("invalid contentHash %q", e.ContentHash) + } + return e +} + +// Catalogs returns the registered catalogs in registration order. +func (r *Registry) Catalogs() []Catalog { + catalogs := make([]Catalog, 0, len(r.entries)) + for _, e := range r.entries { + if e.err == nil { + catalogs = append(catalogs, e.Catalog) + } + } + return catalogs +} + +// Lookup returns the registered catalog with the name or source nameOrSource, +// compared ignoring case. +func (r *Registry) Lookup(nameOrSource string) (Catalog, bool) { + if e := r.find(nameOrSource); e != nil { + return e.Catalog, true + } + return Catalog{}, false +} + +func (r *Registry) find(nameOrSource string) *registryEntry { + for _, e := range r.entries { + if e.err == nil && (strings.EqualFold(e.Name, nameOrSource) || strings.EqualFold(e.Source, nameOrSource)) { + return e + } + } + return nil +} + +func (r *Registry) writable() error { + if r.Problem != nil { + return fmt.Errorf("refusing to modify the store: %w", r.Problem) + } + return nil +} + +func (r *Registry) save() error { + raws := make([]map[string]json.RawMessage, 0, len(r.entries)) + for _, e := range r.entries { + raws = append(raws, e.raw) + } + entries, err := json.Marshal(raws) + if err != nil { + return err + } + r.members["entries"] = entries + data, err := json.MarshalIndent(r.members, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(r.store.Dir, 0o755); err != nil { + return fmt.Errorf("creating catalog store: %w", err) + } + return writeFileAtomic(r.store.path(registryFile), data) +} + +func (r *Registry) saveRefs() error { + data, err := json.MarshalIndent(r.refs, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(r.store.Dir, 0o755); err != nil { + return fmt.Errorf("creating catalog store: %w", err) + } + return writeFileAtomic(r.store.path(refsFile), data) +} + +func (e *registryEntry) set(c Catalog, objectURL string) error { + e.Catalog = c + if e.ext == nil { + e.ext = map[string]json.RawMessage{} + } + e.ext["sourceUrl"] = mustMarshal(c.Source) + e.ext["contentHash"] = mustMarshal(c.ContentHash) + e.ext["entryCount"] = mustMarshal(c.EntryCount) + e.ext["lastUpdated"] = mustMarshal(c.LastUpdated) + + extensions := map[string]json.RawMessage{} + if data, ok := e.raw["extensions"]; ok { + if err := json.Unmarshal(data, &extensions); err != nil { + return fmt.Errorf("decoding extensions of %q: %w", c.Name, err) + } + } + ext, err := json.Marshal(e.ext) + if err != nil { + return err + } + extensions[extensionKey] = ext + e.raw["extensions"] = mustMarshal(extensions) + e.raw["url"] = mustMarshal(objectURL) + e.raw["updatedAt"] = mustMarshal(c.LastUpdated) + return nil +} + +func (s *Store) path(name string) string { return filepath.Join(s.Dir, name) } + +func (s *Store) objectPath(hash string) (string, error) { + if !isContentHash(hash) { + return "", fmt.Errorf("invalid content hash %q", hash) + } + return filepath.Join(s.Dir, objectsDir, hash+".json"), nil +} + +// objectURL is the file URL ai-catalog-cli records as a registry entry's url. +func (s *Store) objectURL(hash string) string { + path, err := s.objectPath(hash) + if err != nil { + return "" + } + if abs, err := filepath.Abs(path); err == nil { + path = abs + } + return (&url.URL{Scheme: "file", Path: filepath.ToSlash(path)}).String() +} + +func (s *Store) readObject(hash string) ([]byte, error) { + path, err := s.objectPath(hash) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading stored catalog: %w", err) + } + return data, nil +} + +func (s *Store) writeObject(data []byte) (string, error) { + sum := sha256.Sum256(data) + hash := hex.EncodeToString(sum[:]) + path, err := s.objectPath(hash) + if err != nil { + return "", err + } + if _, err := os.Stat(path); err == nil { + return hash, nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", fmt.Errorf("creating catalog store: %w", err) + } + if err := writeFileAtomic(path, data); err != nil { + return "", err + } + return hash, nil +} + +func (s *Store) removeObject(hash string) error { + path, err := s.objectPath(hash) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("removing stored catalog: %w", err) + } + return nil +} + +func isContentHash(s string) bool { + if len(s) != sha256.Size*2 { + return false + } + _, err := hex.DecodeString(s) + return err == nil +} + +// writeFileAtomic replaces path with data, so a reader never sees a partial file. +func writeFileAtomic(path string, data []byte) (err error) { + f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + defer func() { + if err != nil { + err = errors.Join(err, os.Remove(f.Name())) + } + }() + if _, err := f.Write(data); err != nil { + return errors.Join(fmt.Errorf("writing %s: %w", path, err), f.Close()) + } + if err := f.Chmod(0o644); err != nil { + return errors.Join(fmt.Errorf("writing %s: %w", path, err), f.Close()) + } + if err := f.Close(); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + if err := os.Rename(f.Name(), path); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + return nil +} + +func mustMarshal(v any) json.RawMessage { + data, err := json.Marshal(v) + if err != nil { + panic(fmt.Sprintf("aicatalog: marshal %T: %v", v, err)) + } + return data +} diff --git a/internal/aicatalog/store_test.go b/internal/aicatalog/store_test.go new file mode 100644 index 0000000..579d4df --- /dev/null +++ b/internal/aicatalog/store_test.go @@ -0,0 +1,559 @@ +// 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 aicatalog + +import ( + "encoding/json" + "errors" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +const fixtureStore = "testdata/ai-catalog-cli-store" + +// catalogServer serves catalog documents by path and lets tests change them. +type catalogServer struct { + *httptest.Server + mu sync.Mutex + docs map[string]string +} + +func newCatalogServer(t *testing.T, docs map[string]string) *catalogServer { + t.Helper() + s := &catalogServer{docs: docs} + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + doc, ok := s.docs[r.URL.Path] + s.mu.Unlock() + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/ai-catalog+json") + if _, err := w.Write([]byte(doc)); err != nil { + t.Errorf("writing %s: %v", r.URL.Path, err) + } + })) + t.Cleanup(s.Close) + return s +} + +func (s *catalogServer) set(path, doc string) { + s.mu.Lock() + defer s.mu.Unlock() + s.docs[path] = doc +} + +func (s *catalogServer) fetcher() *Fetcher { return &Fetcher{Client: s.Client()} } + +func copyStore(t *testing.T, src string) string { + t.Helper() + dst := t.TempDir() + err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, 0o644) + }) + if err != nil { + t.Fatalf("copying %s: %v", src, err) + } + return dst +} + +func mustOpen(t *testing.T, dir string) *Registry { + t.Helper() + reg, err := (&Store{Dir: dir}).Open() + if err != nil { + t.Fatalf("Store.Open() error = %v", err) + } + return reg +} + +func identifiers(entries []Entry) []string { + ids := []string{} + for _, e := range entries { + ids = append(ids, e.Catalog+" "+e.Identifier) + } + return ids +} + +func listObjects(t *testing.T, dir string) []string { + t.Helper() + files, err := os.ReadDir(filepath.Join(dir, objectsDir)) + if err != nil { + t.Fatalf("os.ReadDir() error = %v", err) + } + names := []string{} + for _, f := range files { + names = append(names, f.Name()) + } + return names +} + +func TestStoreReadsAICatalogCLIStore(t *testing.T) { + t.Parallel() + reg := mustOpen(t, fixtureStore) + if reg.Problem != nil { + t.Fatalf("Store.Open() Problem = %v, want nil", reg.Problem) + } + + wantCatalogs := []Catalog{{ + Name: "corp", + Source: "file:///home/user/catalogs/corp.json", + ContentHash: "1ca21b2f25e1e7ffdbe3d7628b1b79c6f73ac36e1e69b1e4d9df6e7549926c2a", + EntryCount: 3, + LastUpdated: "2026-11-12T14:40:39.268982908+00:00", + }} + if diff := cmp.Diff(wantCatalogs, reg.Catalogs()); diff != "" { + t.Fatalf("Registry.Catalogs() wrong result (-want +got) diff = %s", diff) + } + + entries, warnings, err := reg.Entries(t.Context(), "CORP") + if err != nil { + t.Fatalf("Registry.Entries() error = %v", err) + } + if len(warnings) > 0 { + t.Fatalf("Registry.Entries() warnings = %v, want none", warnings) + } + wantAgents := []string{"corp urn:air:acme.com:agent:finance", "corp urn:air:acme.com:agent:hr"} + if diff := cmp.Diff(wantAgents, identifiers(Agents(entries))); diff != "" { + t.Fatalf("Agents() wrong result (-want +got) diff = %s", diff) + } +} + +func TestStoreMissingIsEmpty(t *testing.T) { + t.Parallel() + reg := mustOpen(t, filepath.Join(t.TempDir(), "missing")) + if reg.Problem != nil { + t.Fatalf("Store.Open() Problem = %v, want nil", reg.Problem) + } + if got := reg.Catalogs(); len(got) != 0 { + t.Fatalf("Registry.Catalogs() = %v, want empty", got) + } +} + +func TestRegistryAddUpdateRemove(t *testing.T) { + t.Parallel() + srv := newCatalogServer(t, map[string]string{ + "/.well-known/ai-catalog.json": `{"specVersion":"1.0","entries":[ + {"identifier":"urn:a:agent","type":"application/a2a-agent-card+json","url":"/a.json"}, + {"identifier":"urn:a:nested","type":"application/ai-catalog+json","url":"/nested.json"}]}`, + "/nested.json": `{"specVersion":"1.0","entries":[ + {"identifier":"urn:b:agent","type":"application/a2a-agent-card+json","url":"/b.json"}]}`, + }) + dir := t.TempDir() + ctx := t.Context() + + res, err := mustOpen(t, dir).Add(ctx, srv.fetcher(), "Corp", srv.URL) + if err != nil { + t.Fatalf("Registry.Add() error = %v", err) + } + wantAdded := Catalog{Name: "Corp", Source: srv.URL + "/.well-known/ai-catalog.json", EntryCount: 2} + if diff := cmp.Diff(wantAdded, res.Catalog, cmpopts.IgnoreFields(Catalog{}, "ContentHash", "LastUpdated")); diff != "" { + t.Fatalf("Registry.Add() wrong result (-want +got) diff = %s", diff) + } + + reg := mustOpen(t, dir) + entries, _, err := reg.Entries(ctx, "") + if err != nil { + t.Fatalf("Registry.Entries() error = %v", err) + } + wantAgents := []string{"Corp urn:a:agent", "Corp urn:b:agent"} + if diff := cmp.Diff(wantAgents, identifiers(Agents(entries))); diff != "" { + t.Fatalf("Registry.Entries() wrong result (-want +got) diff = %s", diff) + } + + res, err = reg.Update(ctx, srv.fetcher(), "corp") + if err != nil { + t.Fatalf("Registry.Update() error = %v", err) + } + if res.Changed { + t.Fatalf("Registry.Update() Changed = true, want false for unchanged content") + } + + srv.set("/nested.json", `{"specVersion":"1.0","entries":[]}`) + srv.set("/.well-known/ai-catalog.json", `{"specVersion":"1.0","entries":[ + {"identifier":"urn:c:agent","type":"application/a2a-agent-card+json","url":"/c.json"}]}`) + res, err = reg.Update(ctx, srv.fetcher(), "corp") + if err != nil { + t.Fatalf("Registry.Update() error = %v", err) + } + if !res.Changed || res.Catalog.EntryCount != 1 { + t.Fatalf("Registry.Update() = %+v, want Changed with 1 entry", res) + } + entries, _, err = mustOpen(t, dir).Entries(ctx, "") + if err != nil { + t.Fatalf("Registry.Entries() error = %v", err) + } + if diff := cmp.Diff([]string{"Corp urn:c:agent"}, identifiers(entries)); diff != "" { + t.Fatalf("Registry.Entries() after update wrong result (-want +got) diff = %s", diff) + } + + removed, err := mustOpen(t, dir).Remove(ctx, srv.URL+"/.well-known/ai-catalog.json") + if err != nil { + t.Fatalf("Registry.Remove() error = %v", err) + } + if removed.Name != "Corp" { + t.Fatalf("Registry.Remove() Name = %q, want %q", removed.Name, "Corp") + } + reg = mustOpen(t, dir) + if got := reg.Catalogs(); len(got) != 0 { + t.Fatalf("Registry.Catalogs() after remove = %v, want empty", got) + } + for _, hash := range reg.refs { + if hash == res.Catalog.ContentHash { + t.Fatalf("refs still map to the removed catalog: %v", reg.refs) + } + } + if slices.Contains(listObjects(t, dir), res.Catalog.ContentHash+".json") { + t.Fatalf("stored object of the removed catalog was not deleted") + } +} + +func TestRegistryAddKeepsOtherEntriesIntact(t *testing.T) { + t.Parallel() + dir := copyStore(t, fixtureStore) + before := readRegistryEntries(t, dir) + srv := newCatalogServer(t, map[string]string{"/c.json": `{"specVersion":"1.0","entries":[]}`}) + + if _, err := mustOpen(t, dir).Add(t.Context(), srv.fetcher(), "other", srv.URL+"/c.json"); err != nil { + t.Fatalf("Registry.Add() error = %v", err) + } + + after := readRegistryEntries(t, dir) + if diff := cmp.Diff(before, after[:1]); diff != "" { + t.Fatalf("existing registry entry changed (-want +got) diff = %s", diff) + } + added := after[1] + ext := added["extensions"].(map[string]any)[extensionKey].(map[string]any) + if added["identifier"] != registryIdentifier("other") || ext["sourceUrl"] != srv.URL+"/c.json" { + t.Fatalf("added registry entry = %v, want ai-catalog-cli bookkeeping", added) + } +} + +func readRegistryEntries(t *testing.T, dir string) []map[string]any { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, registryFile)) + if err != nil { + t.Fatalf("os.ReadFile() error = %v", err) + } + var doc struct { + Entries []map[string]any `json:"entries"` + } + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + return doc.Entries +} + +func TestRegistryIdentifierMatchesAICatalogCLI(t *testing.T) { + t.Parallel() + if got, want := registryIdentifier("Corp"), "urn:ai-catalog:local:19f4c684"; got != want { + t.Fatalf("registryIdentifier() = %v, want %v", got, want) + } +} + +func TestRegistryAddErrors(t *testing.T) { + t.Parallel() + srv := newCatalogServer(t, map[string]string{ + "/c.json": `{"specVersion":"1.0","entries":[]}`, + "/card.json": `{"name":"not a catalog"`, + }) + + tests := []struct { + name string + catName string + source string + wantErr string + }{ + {name: "name taken ignoring case", catName: "CORP", source: srv.URL + "/c.json", wantErr: "already registered"}, + {name: "source taken", catName: "other", source: "file:///home/user/catalogs/corp.json", wantErr: `already registered as "corp"`}, + {name: "not found", catName: "other", source: srv.URL + "/missing.json", wantErr: "404"}, + {name: "not a catalog", catName: "other", source: srv.URL + "/card.json", wantErr: "not an AI Catalog"}, + {name: "empty name", catName: " ", source: srv.URL + "/c.json", wantErr: "name must not be empty"}, + {name: "unsupported scheme", catName: "other", source: "ftp://example.com/c.json", wantErr: "unsupported catalog source"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := copyStore(t, fixtureStore) + _, err := mustOpen(t, dir).Add(t.Context(), srv.fetcher(), tt.catName, tt.source) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Registry.Add() error = %v, want containing %q", err, tt.wantErr) + } + if got := mustOpen(t, dir).Catalogs(); len(got) != 1 { + t.Fatalf("Registry.Catalogs() = %v, want only the fixture catalog", got) + } + }) + } +} + +func TestRegistryRemoveKeepsSharedObjects(t *testing.T) { + t.Parallel() + dir := copyStore(t, fixtureStore) + ctx := t.Context() + nested := "file://" + filepath.ToSlash(writeFile(t, "hr.json", `{"specVersion":"1.0","entries":[]}`)) + if _, err := mustOpen(t, dir).Add(ctx, &Fetcher{}, "hr", nested); err != nil { + t.Fatalf("Registry.Add() error = %v", err) + } + reg := mustOpen(t, dir) + reg.refs["file:///home/user/catalogs/hr.json"] = reg.find("hr").ContentHash + if err := reg.saveRefs(); err != nil { + t.Fatalf("saveRefs() error = %v", err) + } + before := listObjects(t, dir) + + if _, err := mustOpen(t, dir).Remove(ctx, "hr"); err != nil { + t.Fatalf("Registry.Remove() error = %v", err) + } + + if diff := cmp.Diff(before, listObjects(t, dir)); diff != "" { + t.Fatalf("Registry.Remove() deleted an object still used as a nested catalog (-want +got) diff = %s", diff) + } +} + +func writeFile(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + return path +} + +func TestRegistryUnknownCatalog(t *testing.T) { + t.Parallel() + dir := copyStore(t, fixtureStore) + reg := mustOpen(t, dir) + var unknown *UnknownCatalogError + if _, err := reg.Update(t.Context(), &Fetcher{}, "nope"); !errors.As(err, &unknown) { + t.Fatalf("Registry.Update() error = %v, want *UnknownCatalogError", err) + } + if _, err := reg.Remove(t.Context(), "nope"); !errors.As(err, &unknown) { + t.Fatalf("Registry.Remove() error = %v, want *UnknownCatalogError", err) + } + if _, _, err := reg.Entries(t.Context(), "nope"); !errors.As(err, &unknown) { + t.Fatalf("Registry.Entries() error = %v, want *UnknownCatalogError", err) + } +} + +func TestStoreUnsupportedFormat(t *testing.T) { + t.Parallel() + validEntry := `{"identifier":"urn:ai-catalog:local:1","displayName":"ok","type":"application/ai-catalog+json", + "extensions":{"org.ai-catalog.ai-catalog-cli":{"sourceUrl":"https://ok.example/c.json", + "contentHash":"1ca21b2f25e1e7ffdbe3d7628b1b79c6f73ac36e1e69b1e4d9df6e7549926c2a"}}}` + + tests := []struct { + name string + registry string + refs string + wantCatalogs []string + }{ + {name: "registry is not JSON", registry: `{`}, + {name: "registry has unknown major version", registry: `{"specVersion":"2.0","entries":[]}`}, + {name: "entries is not a list", registry: `{"specVersion":"1.0","entries":{}}`}, + { + name: "entry without bookkeeping", + registry: `{"specVersion":"1.0","entries":[` + validEntry + `,{"identifier":"x","displayName":"bad","type":"application/ai-catalog+json"}]}`, + wantCatalogs: []string{"ok"}, + }, + { + name: "entry with invalid hash", + registry: `{"specVersion":"1.0","entries":[{"identifier":"x","displayName":"bad","type":"application/ai-catalog+json","extensions":{"org.ai-catalog.ai-catalog-cli":{"sourceUrl":"https://x","contentHash":"../../etc/passwd"}}}]}`, + wantCatalogs: []string{}, + }, + { + name: "refs is not a map", + registry: `{"specVersion":"1.0","entries":[` + validEntry + `]}`, + refs: `[]`, + wantCatalogs: []string{"ok"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, registryFile), []byte(tt.registry), 0o644); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + if tt.refs != "" { + if err := os.WriteFile(filepath.Join(dir, refsFile), []byte(tt.refs), 0o644); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + } + + reg := mustOpen(t, dir) + var unsupported *UnsupportedFormatError + if !errors.As(reg.Problem, &unsupported) { + t.Fatalf("Store.Open() Problem = %v, want *UnsupportedFormatError", reg.Problem) + } + names := []string{} + for _, c := range reg.Catalogs() { + names = append(names, c.Name) + } + if diff := cmp.Diff(tt.wantCatalogs, names, cmpopts.EquateEmpty()); diff != "" { + t.Fatalf("Registry.Catalogs() wrong result (-want +got) diff = %s", diff) + } + if _, err := reg.Add(t.Context(), &Fetcher{}, "new", "https://new.example/c.json"); !errors.As(err, &unsupported) { + t.Fatalf("Registry.Add() error = %v, want *UnsupportedFormatError", err) + } + if _, err := reg.Remove(t.Context(), "ok"); !errors.As(err, &unsupported) { + t.Fatalf("Registry.Remove() error = %v, want *UnsupportedFormatError", err) + } + data, err := os.ReadFile(filepath.Join(dir, registryFile)) + if err != nil { + t.Fatalf("os.ReadFile() error = %v", err) + } + if string(data) != tt.registry { + t.Fatalf("registry was modified: %s", data) + } + }) + } +} + +func TestLoadNestedCatalogs(t *testing.T) { + t.Parallel() + srv := newCatalogServer(t, map[string]string{ + "/a.json": `{"specVersion":"1.0","entries":[ + {"identifier":"urn:a","type":"application/a2a-agent-card+json","url":"a-card.json"}, + {"identifier":"urn:to-b","type":"application/ai-catalog+json","url":"b.json"}, + {"identifier":"urn:inline","type":"application/ai-catalog+json","data":{"specVersion":"1.0","entries":[ + {"identifier":"urn:i","type":"application/a2a-agent-card+json","data":{"name":"I"}}]}}, + {"identifier":"urn:missing","type":"application/ai-catalog+json","url":"missing.json"}, + {"identifier":"urn:local","type":"application/ai-catalog+json","url":"file:///etc/passwd"}]}`, + "/b.json": `{"specVersion":"1.0","entries":[ + {"identifier":"urn:b","type":"application/a2a-agent-card+json","url":"b-card.json"}, + {"identifier":"urn:back","type":"application/ai-catalog+json","url":"a.json"}]}`, + }) + source := srv.URL + "/a.json" + data := srv.docs["/a.json"] + + entries, warnings, err := Load(t.Context(), srv.fetcher(), source, []byte(data)) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + wantAgents := []string{source + " urn:a", source + " urn:b", source + " urn:i"} + if diff := cmp.Diff(wantAgents, identifiers(Agents(entries))); diff != "" { + t.Fatalf("Load() agents wrong result (-want +got) diff = %s", diff) + } + sort.Strings(warnings) + wantWarnings := []string{"circular reference", "404", "may not be loaded from"} + if len(warnings) != len(wantWarnings) { + t.Fatalf("Load() warnings = %q, want %d", warnings, len(wantWarnings)) + } + for _, want := range wantWarnings { + if !strings.Contains(strings.Join(warnings, "\n"), want) { + t.Fatalf("Load() warnings = %q, want one containing %q", warnings, want) + } + } + + agent, err := FindAgent(entries, "urn:b", source) + if err != nil { + t.Fatalf("FindAgent() error = %v", err) + } + cardURL, err := CardURL(agent) + if err != nil { + t.Fatalf("CardURL() error = %v", err) + } + if want := srv.URL + "/b-card.json"; cardURL != want { + t.Fatalf("CardURL() = %v, want %v", cardURL, want) + } +} + +func TestLoadStopsAtMaxDepth(t *testing.T) { + t.Parallel() + docs := map[string]string{} + for i := 0; i <= maxDepth+1; i++ { + docs["/"+string(rune('0'+i))+".json"] = `{"specVersion":"1.0","entries":[ + {"identifier":"urn:agent:` + string(rune('0'+i)) + `","type":"application/a2a-agent-card+json","url":"card.json"}, + {"identifier":"urn:next","type":"application/ai-catalog+json","url":"` + string(rune('0'+i+1)) + `.json"}]}` + } + srv := newCatalogServer(t, docs) + + entries, warnings, err := Load(t.Context(), srv.fetcher(), srv.URL+"/0.json", []byte(docs["/0.json"])) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got := len(Agents(entries)); got != maxDepth+1 { + t.Fatalf("Load() found %d agents, want %d", got, maxDepth+1) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "nesting deeper") { + t.Fatalf("Load() warnings = %q, want one about nesting depth", warnings) + } +} + +func TestResolveReference(t *testing.T) { + t.Parallel() + tests := []struct { + name string + base string + ref string + want string + wantErr bool + }{ + {name: "relative to https", base: "https://a.example/c/cat.json", ref: "card.json", want: "https://a.example/c/card.json"}, + {name: "absolute https", base: "https://a.example/cat.json", ref: "https://b.example/card.json", want: "https://b.example/card.json"}, + {name: "http from http", base: "http://127.0.0.1:8080/cat.json", ref: "card.json", want: "http://127.0.0.1:8080/card.json"}, + {name: "relative to file", base: "file:///home/u/cat.json", ref: "../x/sub.json", want: "file:///home/x/sub.json"}, + {name: "http from file", base: "file:///home/u/cat.json", ref: "http://localhost/card.json", want: "http://localhost/card.json"}, + {name: "file from https", base: "https://a.example/cat.json", ref: "file:///etc/passwd", wantErr: true}, + {name: "http from https", base: "https://a.example/cat.json", ref: "http://a.example/card.json", wantErr: true}, + {name: "other scheme", base: "https://a.example/cat.json", ref: "ftp://a.example/card.json", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := resolveReference(tt.base, tt.ref) + if tt.wantErr != (err != nil) { + t.Fatalf("resolveReference() error = %v, want error = %v", err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("resolveReference() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFetcherRejectsLargeDocuments(t *testing.T) { + t.Parallel() + path := writeFile(t, "big.json", strings.Repeat(" ", maxDocumentBytes+1)) + if _, err := (&Fetcher{}).Fetch(t.Context(), "file://"+path); err == nil || !strings.Contains(err.Error(), "larger than") { + t.Fatalf("Fetcher.Fetch() error = %v, want a size error", err) + } +} diff --git a/internal/aicatalog/testdata/ai-catalog-cli-store/catalog.json b/internal/aicatalog/testdata/ai-catalog-cli-store/catalog.json new file mode 100644 index 0000000..98c3ef5 --- /dev/null +++ b/internal/aicatalog/testdata/ai-catalog-cli-store/catalog.json @@ -0,0 +1,23 @@ +{ + "specVersion": "1.0", + "host": { + "displayName": "ai-catalog local registry" + }, + "entries": [ + { + "identifier": "urn:ai-catalog:local:19f4c684", + "displayName": "corp", + "type": "application/ai-catalog+json", + "url": "file:///home/user/.ai-catalog/objects/1ca21b2f25e1e7ffdbe3d7628b1b79c6f73ac36e1e69b1e4d9df6e7549926c2a.json", + "updatedAt": "2026-11-12T14:40:39.268986794+00:00", + "extensions": { + "org.ai-catalog.ai-catalog-cli": { + "contentHash": "1ca21b2f25e1e7ffdbe3d7628b1b79c6f73ac36e1e69b1e4d9df6e7549926c2a", + "entryCount": 3, + "lastUpdated": "2026-11-12T14:40:39.268982908+00:00", + "sourceUrl": "file:///home/user/catalogs/corp.json" + } + } + } + ] +} \ No newline at end of file diff --git a/internal/aicatalog/testdata/ai-catalog-cli-store/objects/1ca21b2f25e1e7ffdbe3d7628b1b79c6f73ac36e1e69b1e4d9df6e7549926c2a.json b/internal/aicatalog/testdata/ai-catalog-cli-store/objects/1ca21b2f25e1e7ffdbe3d7628b1b79c6f73ac36e1e69b1e4d9df6e7549926c2a.json new file mode 100644 index 0000000..23a4a9f --- /dev/null +++ b/internal/aicatalog/testdata/ai-catalog-cli-store/objects/1ca21b2f25e1e7ffdbe3d7628b1b79c6f73ac36e1e69b1e4d9df6e7549926c2a.json @@ -0,0 +1,5 @@ +{"specVersion":"1.0","host":{"displayName":"Acme"},"entries":[ + {"identifier":"urn:air:acme.com:agent:finance","displayName":"Finance","type":"application/a2a-agent-card+json","url":"https://acme.example/finance.json","description":"Books and invoices","tags":["money"]}, + {"identifier":"urn:air:acme.com:mcp:weather","type":"application/mcp-server-card+json","url":"https://acme.example/weather.json"}, + {"identifier":"urn:air:acme.com:catalog:hr","type":"application/ai-catalog+json","url":"hr.json"} +]} diff --git a/internal/aicatalog/testdata/ai-catalog-cli-store/objects/b4d67cbbb90a20cea8a8b0246a48e97f4d3cd016323bdb5a9f2bf9bba5ab8c8f.json b/internal/aicatalog/testdata/ai-catalog-cli-store/objects/b4d67cbbb90a20cea8a8b0246a48e97f4d3cd016323bdb5a9f2bf9bba5ab8c8f.json new file mode 100644 index 0000000..9eeedb3 --- /dev/null +++ b/internal/aicatalog/testdata/ai-catalog-cli-store/objects/b4d67cbbb90a20cea8a8b0246a48e97f4d3cd016323bdb5a9f2bf9bba5ab8c8f.json @@ -0,0 +1 @@ +{"specVersion":"1.0","entries":[{"identifier":"urn:air:acme.com:agent:hr","displayName":"HR","type":"application/a2a-agent-card+json","data":{"name":"HR","version":"1.0.0","supportedInterfaces":[{"url":"https://hr.acme.example","protocolBinding":"HTTP+JSON","protocolVersion":"1.0"}]}}]} diff --git a/internal/aicatalog/testdata/ai-catalog-cli-store/refs.json b/internal/aicatalog/testdata/ai-catalog-cli-store/refs.json new file mode 100644 index 0000000..1427cd8 --- /dev/null +++ b/internal/aicatalog/testdata/ai-catalog-cli-store/refs.json @@ -0,0 +1,4 @@ +{ + "file:///home/user/catalogs/hr.json": "b4d67cbbb90a20cea8a8b0246a48e97f4d3cd016323bdb5a9f2bf9bba5ab8c8f", + "file:///home/user/catalogs/corp.json": "1ca21b2f25e1e7ffdbe3d7628b1b79c6f73ac36e1e69b1e4d9df6e7549926c2a" +} \ No newline at end of file diff --git a/internal/aicatalog/walk.go b/internal/aicatalog/walk.go new file mode 100644 index 0000000..ada932c --- /dev/null +++ b/internal/aicatalog/walk.go @@ -0,0 +1,246 @@ +// 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 aicatalog + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "slices" + + "github.com/Agent-Card/ai-catalog-go/catalog" +) + +const ( + // maxDocumentBytes caps a fetched catalog document. + maxDocumentBytes = 10 << 20 + // maxDepth is how many levels of nested catalogs are followed. + maxDepth = 4 +) + +// Fetcher retrieves catalog documents over HTTP or from local files. +type Fetcher struct { + Client *http.Client +} + +// Fetch returns the document at rawURL, an http, https or file URL. +func (f *Fetcher) Fetch(ctx context.Context, rawURL string) ([]byte, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("invalid catalog URL %q: %w", rawURL, err) + } + switch u.Scheme { + case "file": + return readFileURL(u) + case "http", "https": + default: + return nil, fmt.Errorf("unsupported catalog URL %q: want http, https or file", rawURL) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", catalog.MediaTypeCatalog+", application/json") + client := f.Client + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + data, err := readCapped(resp.Body, rawURL) + if cerr := resp.Body.Close(); cerr != nil && err == nil { + err = cerr + } + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, &StatusError{URL: rawURL, StatusCode: resp.StatusCode, Status: resp.Status} + } + return data, nil +} + +// StatusError reports a catalog request answered with a non-OK status. +type StatusError struct { + URL string + StatusCode int + Status string +} + +func (e *StatusError) Error() string { + return fmt.Sprintf("fetching %s: %s", e.URL, e.Status) +} + +func readFileURL(u *url.URL) ([]byte, error) { + if u.Host != "" && u.Host != "localhost" { + return nil, fmt.Errorf("unsupported file URL host %q", u.Host) + } + f, err := os.Open(u.Path) + if err != nil { + return nil, err + } + data, err := readCapped(f, u.String()) + if cerr := f.Close(); cerr != nil && err == nil { + err = cerr + } + return data, err +} + +func readCapped(r io.Reader, name string) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, maxDocumentBytes+1)) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", name, err) + } + if len(data) > maxDocumentBytes { + return nil, fmt.Errorf("%s is larger than %d MiB", name, maxDocumentBytes>>20) + } + return data, nil +} + +// Entry is a catalog entry and where it was found. +type Entry struct { + catalog.CatalogEntry + // Catalog is the name of the registered catalog the entry was found in, or + // the URL of the catalog when it is not registered. + Catalog string + // Base is the URL of the catalog document that lists the entry, against + // which a relative entry URL resolves. + Base string +} + +// walker collects the entries of a catalog and the catalogs nested in it. An +// online walker fetches nested catalogs; an offline one reads them from the +// store. Problems with a nested catalog become warnings. +type walker struct { + store *Store + refs map[string]string + fetcher *Fetcher + // record keeps fetched documents in the store and refs. + record bool + + label string + path []string + entries []Entry + warnings []string +} + +func (w *walker) walk(ctx context.Context, doc *catalog.AICatalog, base string, depth int) { + for _, entry := range doc.Entries { + w.entries = append(w.entries, Entry{CatalogEntry: entry, Catalog: w.label, Base: base}) + if !entry.IsNestedCatalog() { + continue + } + if depth >= maxDepth { + w.warn(entry, fmt.Errorf("nesting deeper than %d levels", maxDepth)) + continue + } + child, childBase, err := w.open(ctx, &entry, base) + if err != nil { + w.warn(entry, err) + continue + } + if child == nil { + continue + } + w.path = append(w.path, childBase) + w.walk(ctx, child, childBase, depth+1) + w.path = w.path[:len(w.path)-1] + } +} + +func (w *walker) warn(entry catalog.CatalogEntry, err error) { + w.warnings = append(w.warnings, fmt.Sprintf("skipping nested catalog %q in %s: %v", entry.Identifier, w.label, err)) +} + +func (w *walker) open(ctx context.Context, entry *catalog.CatalogEntry, base string) (*catalog.AICatalog, string, error) { + if len(entry.Data) > 0 { + doc, err := catalog.Parse(entry.Data) + return doc, base, err + } + if entry.URL == "" { + return nil, "", nil + } + ref, err := resolveReference(base, entry.URL) + if err != nil { + return nil, "", err + } + if slices.Contains(w.path, ref) { + return nil, "", fmt.Errorf("circular reference to %s", ref) + } + data, err := w.load(ctx, ref) + if err != nil { + return nil, "", err + } + doc, err := catalog.Parse(data) + return doc, ref, err +} + +func (w *walker) load(ctx context.Context, ref string) ([]byte, error) { + if w.fetcher == nil { + hash, ok := w.refs[ref] + if !ok { + return nil, fmt.Errorf("%s is not in the local store; run `a2a catalog update`", ref) + } + return w.store.readObject(hash) + } + data, err := w.fetcher.Fetch(ctx, ref) + if err != nil { + return nil, err + } + if _, err := catalog.Parse(data); err != nil { + return nil, err + } + if w.record { + hash, err := w.store.writeObject(data) + if err != nil { + return nil, err + } + w.refs[ref] = hash + } + return data, nil +} + +// resolveReference resolves ref against the catalog at base. Any catalog may +// reference https URLs, a catalog served over http or read from a local file +// may reference http URLs, and only a local catalog may reference local files. +func resolveReference(base, ref string) (string, error) { + b, err := url.Parse(base) + if err != nil { + return "", fmt.Errorf("invalid catalog URL %q: %w", base, err) + } + r, err := url.Parse(ref) + if err != nil { + return "", fmt.Errorf("invalid reference %q: %w", ref, err) + } + u := b.ResolveReference(r) + if u.Scheme == "file" { + u.Path = path.Clean(u.Path) + } + switch { + case u.Scheme == "https": + case u.Scheme == "http" && (b.Scheme == "http" || b.Scheme == "file"): + case u.Scheme == "file" && b.Scheme == "file": + default: + return "", fmt.Errorf("%s may not be loaded from %s", u, base) + } + return u.String(), nil +} diff --git a/internal/cli/agentref.go b/internal/cli/agentref.go new file mode 100644 index 0000000..af6ba3f --- /dev/null +++ b/internal/cli/agentref.go @@ -0,0 +1,248 @@ +// 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 cli + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "slices" + "strings" + + "github.com/a2aproject/a2a-cli/internal/aicatalog" + "github.com/a2aproject/a2a-cli/internal/clierr" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" +) + +const ( + // maxListedAgents caps the agents named in a hint. + maxListedAgents = 10 + // allCatalogsScope describes the scope of a lookup without --catalog. + allCatalogsScope = "the registered catalogs" +) + +// resolveAgentCard resolves --agent-card. An entry identifier, or any reference +// combined with --catalog, is looked up in AI Catalogs. A reference that serves +// an AI Catalog resolves to the catalog's only A2A agent. +func resolveAgentCard(ctx context.Context, cfg *globalConfig) (*a2a.AgentCard, error) { + ref := cfg.agentCard.String() + if cfg.catalog != "" || aicatalog.IsIdentifier(ref) { + return resolveCatalogAgent(ctx, cfg, cfg.catalog, ref) + } + if err := cfg.agentCard.Validate(); err != nil { + return nil, clierr.Usage(err.Error()) + } + cardURL := cfg.agentCard.URL() + card, raw, err := fetchCardDocument(ctx, cfg, cardURL, true) + if err != nil || card != nil { + return card, err + } + + cfg.logf("%s is an AI Catalog, looking for its A2A agent", cardURL) + base := cardRequestURL(cardURL) + entries, warnings, err := aicatalog.Load(ctx, cfg.catalogFetcher(), base, raw) + cfg.logWarnings(warnings) + if err != nil { + return nil, catalogError(err) + } + entry, err := aicatalog.SingleAgent(entries, cfg.agentCard.String()) + if err != nil { + return nil, catalogError(err) + } + return cardFromEntry(ctx, cfg, entry) +} + +func resolveCatalogAgent(ctx context.Context, cfg *globalConfig, scope, id string) (*a2a.AgentCard, error) { + entries, label, err := catalogEntries(ctx, cfg, scope) + if err != nil { + return nil, err + } + entry, err := aicatalog.FindAgent(entries, id, label) + if err != nil { + return nil, catalogError(err) + } + cfg.logf("found %s in %s", id, entry.Catalog) + return cardFromEntry(ctx, cfg, entry) +} + +// catalogEntries returns the entries of the catalog scope, a registered catalog +// name or source or a catalog URL or path, or of every registered catalog when +// scope is empty. It also returns a description of the scope for messages. +func catalogEntries(ctx context.Context, cfg *globalConfig, scope string) ([]aicatalog.Entry, string, error) { + reg, err := cfg.openCatalogs() + if err != nil { + return nil, "", err + } + if scope == "" { + entries, warnings, err := reg.Entries(ctx, "") + cfg.logWarnings(warnings) + return entries, allCatalogsScope, err + } + if c, ok := reg.Lookup(scope); ok { + entries, warnings, err := reg.Entries(ctx, c.Name) + cfg.logWarnings(warnings) + return entries, fmt.Sprintf("catalog %q", c.Name), err + } + if !isCatalogLocation(scope) { + return nil, "", catalogError(&aicatalog.UnknownCatalogError{Name: scope}) + } + + source, err := aicatalog.NormalizeSource(scope) + if err != nil { + return nil, "", clierr.Usage(fmt.Sprintf("invalid --catalog: %v", err)) + } + if err := cfg.initTLS(); err != nil { + return nil, "", err + } + cfg.logf("fetching catalog %s", source) + fetcher := cfg.catalogFetcher() + data, err := fetcher.Fetch(ctx, source) + if err != nil { + return nil, "", catalogError(err) + } + entries, warnings, err := aicatalog.Load(ctx, fetcher, source, data) + cfg.logWarnings(warnings) + if err != nil { + return nil, "", catalogError(err) + } + return entries, source, nil +} + +// isCatalogLocation reports whether a --catalog value that names no registered +// catalog is a URL or local path rather than a mistyped name. +func isCatalogLocation(scope string) bool { + if strings.Contains(scope, "://") { + return true + } + _, err := os.Stat(scope) + return err == nil +} + +// cardFromEntry returns the Agent Card a catalog entry embeds or points to. +func cardFromEntry(ctx context.Context, cfg *globalConfig, entry aicatalog.Entry) (*a2a.AgentCard, error) { + if len(entry.Data) > 0 { + card, err := a2av0.NewAgentCardParser()(entry.Data) + if err != nil { + return nil, clierr.CardResolution(fmt.Errorf("card parsing failed: %w", err)) + } + return card, nil + } + cardURL, err := aicatalog.CardURL(entry) + if err != nil { + return nil, clierr.CardResolution(err) + } + card, _, err := fetchCardDocument(ctx, cfg, cardURL, false) + return card, err +} + +// cardRequestURL returns the URL the card resolver requests for ref, which has +// the well-known path appended when it has no path. +func cardRequestURL(ref string) string { + u, err := url.Parse(ref) + if err != nil || (u.Path != "" && u.Path != "/") { + return ref + } + return u.JoinPath(".well-known", "agent-card.json").String() +} + +func (g *globalConfig) openCatalogs() (*aicatalog.Registry, error) { + dir := g.catalogDir + if dir == "" { + var err error + if dir, err = aicatalog.DefaultDir(); err != nil { + return nil, err + } + } + reg, err := (&aicatalog.Store{Dir: dir}).Open() + if err != nil { + return nil, err + } + if reg.Problem != nil { + if _, err := fmt.Fprintf(g.stderr(), "warning: %v; a2a will read it but not modify it\n", reg.Problem); err != nil { + return nil, err + } + } + return reg, nil +} + +func (g *globalConfig) catalogFetcher() *aicatalog.Fetcher { + return &aicatalog.Fetcher{Client: &http.Client{Transport: g.httpTransport}} +} + +func (g *globalConfig) logWarnings(warnings []string) { + for _, w := range warnings { + g.logf("warning: %s", w) + } +} + +// catalogError classifies a catalog failure and adds remediation. +func catalogError(err error) error { + if _, ok := errors.AsType[*aicatalog.UnknownCatalogError](err); ok { + return &clierr.Error{ + Code: clierr.CodeUsage, + Message: err.Error(), + Hint: "run `a2a catalog list` to see registered catalogs, or pass a catalog URL", + Exit: 2, + } + } + ce := clierr.CardResolution(err) + var ( + notFound *aicatalog.NotFoundError + ambiguous *aicatalog.AmbiguousError + noSingle *aicatalog.NoSingleAgentError + ) + switch { + case errors.As(err, ¬Found): + ce.Hint = "run `a2a catalog search` to find an agent's identifier" + if len(notFound.Agents) == 0 && notFound.Scope == allCatalogsScope { + ce.Hint = "register a catalog with `a2a catalog add `, or pass one with --catalog" + } else if len(notFound.Agents) > 0 { + ce.Hint = "A2A agents in " + notFound.Scope + ": " + listAgents(notFound.Agents) + } + case errors.As(err, &ambiguous): + ce.Hint = "pick a catalog with --catalog " + case errors.As(err, &noSingle) && len(noSingle.Agents) > 0: + ce.Hint = "A2A agents in the catalog: " + listAgents(noSingle.Agents) + } + return ce +} + +func listAgents(agents []aicatalog.Entry) string { + var ids []string + for _, a := range agents { + if !slices.Contains(ids, a.Identifier) { + ids = append(ids, a.Identifier) + } + } + list := strings.Join(ids[:min(len(ids), maxListedAgents)], ", ") + if more := len(ids) - maxListedAgents; more > 0 { + list += fmt.Sprintf(" and %d more", more) + } + return list +} + +// warnAll prints warnings to stderr. +func (g *globalConfig) warnAll(warnings []string) error { + for _, w := range warnings { + if _, err := fmt.Fprintf(g.stderr(), "warning: %s\n", w); err != nil { + return err + } + } + return nil +} diff --git a/internal/cli/card_get.go b/internal/cli/card_get.go index 307d766..31038c6 100644 --- a/internal/cli/card_get.go +++ b/internal/cli/card_get.go @@ -28,7 +28,7 @@ func newCardGetCmd(cfg *globalConfig) *cobra.Command { var extended bool cmd := &cobra.Command{ - Use: "get [url]", + Use: "get [ref]", Short: "Fetch and display an agent card", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -84,8 +84,5 @@ func getPublicAgentCard(ctx context.Context, cfg *globalConfig) (*a2a.AgentCard, if !cfg.agentCard.IsSet() { return nil, clierr.Usage("specify the agent card URL as an argument or with --agent-card") } - if err := cfg.agentCard.Validate(); err != nil { - return nil, clierr.Usage(err.Error()) - } - return resolveCard(ctx, cfg, cfg.agentCard.URL()) + return resolveAgentCard(ctx, cfg) } diff --git a/internal/cli/catalog.go b/internal/cli/catalog.go new file mode 100644 index 0000000..c450f7d --- /dev/null +++ b/internal/cli/catalog.go @@ -0,0 +1,40 @@ +// 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 cli + +import ( + "github.com/spf13/cobra" +) + +func newCatalogCmd(cfg *globalConfig) *cobra.Command { + cmd := &cobra.Command{ + Use: "catalog", + Aliases: []string{"catalogs"}, + Short: "Work with AI Catalogs of agents (experimental)", + Long: "Register AI Catalogs and find the A2A agents they list.\n\n" + + "Catalogs are kept in the store shared with ai-catalog-cli (~/.ai-catalog, or $AI_CATALOG_CACHE_DIR), " + + "so a catalog registered with either tool is visible to both. Pass an agent's identifier to " + + "--agent-card to talk to it.\n\n" + + "These commands are experimental: the store layout is not part of the AI Catalog specification.", + } + cmd.AddCommand( + newCatalogAddCmd(cfg), + newCatalogListCmd(cfg), + newCatalogUpdateCmd(cfg), + newCatalogRemoveCmd(cfg), + newCatalogSearchCmd(cfg), + ) + return cmd +} diff --git a/internal/cli/catalog_add.go b/internal/cli/catalog_add.go new file mode 100644 index 0000000..f0e22fe --- /dev/null +++ b/internal/cli/catalog_add.go @@ -0,0 +1,56 @@ +// 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 cli + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" +) + +func newCatalogAddCmd(cfg *globalConfig) *cobra.Command { + return &cobra.Command{ + Use: "add ", + Short: "Register an AI Catalog", + Long: "Fetch the AI Catalog at a URL or local path, and the catalogs nested in it, " + + "and register it under a name. A URL without a path, or a bare host, gets " + + "the well-known path /.well-known/ai-catalog.json.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(cmd.Context(), cfg.timeout) + defer cancel() + if err := cfg.initTLS(); err != nil { + return err + } + reg, err := cfg.openCatalogs() + if err != nil { + return err + } + res, err := reg.Add(ctx, cfg.catalogFetcher(), args[0], args[1]) + if err != nil { + return err + } + if err := cfg.warnAll(res.Warnings); err != nil { + return err + } + if cfg.IsJSON() { + return cfg.PrintJSON(newCatalogView(res.Catalog)) + } + _, err = fmt.Fprintf(cfg.Out, "Added catalog %q from %s (%d entries)\n", res.Catalog.Name, res.Catalog.Source, res.Catalog.EntryCount) + return err + }, + } +} diff --git a/internal/cli/catalog_list.go b/internal/cli/catalog_list.go new file mode 100644 index 0000000..f1a1ebd --- /dev/null +++ b/internal/cli/catalog_list.go @@ -0,0 +1,80 @@ +// 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 cli + +import ( + "io" + "strconv" + "time" + + "github.com/spf13/cobra" + + "github.com/a2aproject/a2a-cli/internal/aicatalog" + "github.com/a2aproject/a2a-cli/internal/output" +) + +// catalogView is the JSON view of a registered catalog. +type catalogView struct { + Name string `json:"name"` + Source string `json:"source"` + Entries int `json:"entries"` + LastUpdated string `json:"lastUpdated,omitempty"` +} + +func newCatalogView(c aicatalog.Catalog) catalogView { + return catalogView{Name: c.Name, Source: c.Source, Entries: c.EntryCount, LastUpdated: c.LastUpdated} +} + +func newCatalogListCmd(cfg *globalConfig) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List registered AI Catalogs", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + reg, err := cfg.openCatalogs() + if err != nil { + return err + } + views := []catalogView{} + for _, c := range reg.Catalogs() { + views = append(views, newCatalogView(c)) + } + if cfg.IsJSON() { + return cfg.PrintJSON(views) + } + return printCatalogList(cfg.Printer, views) + }, + } +} + +func printCatalogList(p *output.Printer, views []catalogView) error { + if len(views) == 0 { + _, err := io.WriteString(p.Out, "No catalogs registered. Add one with: a2a catalog add \n") + return err + } + rows := make([][]string, 0, len(views)) + for _, v := range views { + rows = append(rows, []string{v.Name, v.Source, strconv.Itoa(v.Entries), formatTimestamp(v.LastUpdated)}) + } + return p.PrintTable([]string{"NAME", "SOURCE", "ENTRIES", "LAST UPDATED"}, rows) +} + +func formatTimestamp(rfc3339 string) string { + t, err := time.Parse(time.RFC3339Nano, rfc3339) + if err != nil { + return dashIfEmpty(rfc3339) + } + return t.Local().Format(time.DateTime) +} diff --git a/internal/cli/catalog_remove.go b/internal/cli/catalog_remove.go new file mode 100644 index 0000000..43211b5 --- /dev/null +++ b/internal/cli/catalog_remove.go @@ -0,0 +1,45 @@ +// 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 cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newCatalogRemoveCmd(cfg *globalConfig) *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Aliases: []string{"rm"}, + Short: "Unregister an AI Catalog", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reg, err := cfg.openCatalogs() + if err != nil { + return err + } + removed, err := reg.Remove(cmd.Context(), args[0]) + if err != nil { + return catalogCmdError("removing", args[0], err) + } + if cfg.IsJSON() { + return cfg.PrintJSON(newCatalogView(removed)) + } + _, err = fmt.Fprintf(cfg.Out, "Removed catalog %q\n", removed.Name) + return err + }, + } +} diff --git a/internal/cli/catalog_search.go b/internal/cli/catalog_search.go new file mode 100644 index 0000000..d3cb9a8 --- /dev/null +++ b/internal/cli/catalog_search.go @@ -0,0 +1,104 @@ +// 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 cli + +import ( + "context" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/a2aproject/a2a-cli/internal/aicatalog" + "github.com/a2aproject/a2a-cli/internal/output" +) + +// catalogAgentView is the JSON view of an A2A agent listed by a catalog. +type catalogAgentView struct { + Identifier string `json:"identifier"` + DisplayName string `json:"displayName,omitempty"` + Version string `json:"version,omitempty"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` + URL string `json:"url,omitempty"` + Catalog string `json:"catalog"` +} + +func newCatalogSearchCmd(cfg *globalConfig) *cobra.Command { + return &cobra.Command{ + Use: "search [query]", + Short: "Find A2A agents in AI Catalogs", + Long: "List the A2A agents (entries of type application/a2a-agent-card+json) whose identifier, " + + "name, description or tags contain the query, ignoring case. Without a query every agent is listed.\n\n" + + "Searches the registered catalogs, or only the one given with --catalog, which may also be " + + "the URL of a catalog that is not registered.", + Example: " a2a catalog search finance\n" + + " a2a catalog search --catalog corp\n" + + " a2a send -a \"hello\"", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(cmd.Context(), cfg.timeout) + defer cancel() + entries, _, err := catalogEntries(ctx, cfg, cfg.catalog) + if err != nil { + return err + } + query := "" + if len(args) == 1 { + query = args[0] + } + views := []catalogAgentView{} + for _, e := range aicatalog.Search(entries, query) { + views = append(views, catalogAgentView{ + Identifier: e.Identifier, + DisplayName: e.DisplayName, + Version: e.Version, + Description: e.Description, + Tags: e.Tags, + URL: e.URL, + Catalog: e.Catalog, + }) + } + if cfg.IsJSON() { + return cfg.PrintJSON(views) + } + return printCatalogAgents(cfg.Printer, views, query) + }, + } +} + +func printCatalogAgents(p *output.Printer, views []catalogAgentView, query string) error { + if len(views) == 0 { + msg := "No A2A agents found.\n" + if query != "" { + msg = fmt.Sprintf("No A2A agents match %q.\n", query) + } + _, err := io.WriteString(p.Out, msg) + return err + } + rows := make([][]string, 0, len(views)) + for _, v := range views { + rows = append(rows, []string{v.Identifier, dashIfEmpty(v.DisplayName), dashIfEmpty(v.Version), v.Catalog, dashIfEmpty(truncate(v.Description, 60))}) + } + return p.PrintTable([]string{"IDENTIFIER", "NAME", "VERSION", "CATALOG", "DESCRIPTION"}, rows) +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n-1]) + "…" +} diff --git a/internal/cli/catalog_test.go b/internal/cli/catalog_test.go new file mode 100644 index 0000000..8ac7d75 --- /dev/null +++ b/internal/cli/catalog_test.go @@ -0,0 +1,329 @@ +// 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 cli + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/a2aproject/a2a-cli/internal/clicfg" + "github.com/a2aproject/a2a-cli/internal/clierr" + "github.com/a2aproject/a2a-cli/internal/polling" + "github.com/a2aproject/a2a-cli/internal/testutil" + "github.com/a2aproject/a2a-go/v2/a2a" +) + +// startCatalogServer serves AI Catalog documents by path. +func startCatalogServer(t *testing.T, docs map[string]string) string { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + doc, ok := docs[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/ai-catalog+json") + if _, err := w.Write([]byte(doc)); err != nil { + t.Errorf("writing %s: %v", r.URL.Path, err) + } + })) + t.Cleanup(server.Close) + return server.URL +} + +func agentCatalog(entries ...string) string { + return `{"specVersion":"1.0","entries":[` + strings.Join(entries, ",") + `]}` +} + +func agentCatalogEntry(id, cardURL string) string { + return fmt.Sprintf(`{"identifier":%q,"type":"application/a2a-agent-card+json","url":%q}`, id, cardURL) +} + +func runCatalogCMD(t *testing.T, dir string, args ...string) (stdout, stderr string, err error) { + t.Helper() + return runCMDCapturingStderrWithConfig(t, deps{poller: polling.Stream, cfgLoader: clicfg.LoadEmpty, catalogDir: dir}, args...) +} + +func mustRunCatalogCMD(t *testing.T, dir string, args ...string) string { + t.Helper() + out, stderr, err := runCatalogCMD(t, dir, args...) + if err != nil { + t.Fatalf("runCatalogCMD(%q) error = %v, stderr = %s", strings.Join(args, " "), err, stderr) + } + return out +} + +func TestCatalogCommands(t *testing.T) { + t.Parallel() + agentURL := startTestServer(t) + catalogURL := startCatalogServer(t, map[string]string{ + "/.well-known/ai-catalog.json": agentCatalog( + agentCatalogEntry("urn:air:test:agent:echo", agentURL+"/.well-known/agent-card.json"), + `{"identifier":"urn:air:test:mcp:tools","type":"application/mcp-server-card+json","url":"/mcp.json"}`, + `{"identifier":"urn:air:test:catalog:lab","type":"application/ai-catalog+json","url":"/lab.json"}`, + ), + "/lab.json": agentCatalog(`{"identifier":"urn:air:test:agent:lab","displayName":"Lab","description":"Experimental agent","type":"application/a2a-agent-card+json","data":{"name":"Lab"}}`), + }) + dir := t.TempDir() + + out := mustRunCatalogCMD(t, dir, "catalog", "add", "test", catalogURL) + if want := fmt.Sprintf("Added catalog %q from %s/.well-known/ai-catalog.json (3 entries)\n", "test", catalogURL); out != want { + t.Fatalf("a2a catalog add = %q, want %q", out, want) + } + + var catalogs []catalogView + if err := json.Unmarshal([]byte(mustRunCatalogCMD(t, dir, "catalog", "list", "-o", "json")), &catalogs); err != nil { + t.Fatalf("json.Unmarshal(catalog list) error = %v", err) + } + wantCatalogs := []catalogView{{Name: "test", Source: catalogURL + "/.well-known/ai-catalog.json", Entries: 3}} + if diff := cmp.Diff(wantCatalogs, catalogs, cmpopts.IgnoreFields(catalogView{}, "LastUpdated")); diff != "" { + t.Fatalf("a2a catalog list wrong result (-want +got) diff = %s", diff) + } + + var agents []catalogAgentView + if err := json.Unmarshal([]byte(mustRunCatalogCMD(t, dir, "catalog", "search", "-o", "json")), &agents); err != nil { + t.Fatalf("json.Unmarshal(catalog search) error = %v", err) + } + wantAgents := []catalogAgentView{ + {Identifier: "urn:air:test:agent:echo", URL: agentURL + "/.well-known/agent-card.json", Catalog: "test"}, + {Identifier: "urn:air:test:agent:lab", DisplayName: "Lab", Description: "Experimental agent", Catalog: "test"}, + } + if diff := cmp.Diff(wantAgents, agents); diff != "" { + t.Fatalf("a2a catalog search wrong result (-want +got) diff = %s", diff) + } + + out = mustRunCatalogCMD(t, dir, "catalog", "search", "experimental") + if !strings.Contains(out, "urn:air:test:agent:lab") || strings.Contains(out, "urn:air:test:agent:echo") { + t.Fatalf("a2a catalog search experimental = %q, want only the lab agent", out) + } + + out = mustRunCatalogCMD(t, dir, "catalog", "update") + if want := "Catalog \"test\" up to date\n"; out != want { + t.Fatalf("a2a catalog update = %q, want %q", out, want) + } + + out = mustRunCatalogCMD(t, dir, "catalog", "remove", "TEST") + if want := "Removed catalog \"test\"\n"; out != want { + t.Fatalf("a2a catalog remove = %q, want %q", out, want) + } + out = mustRunCatalogCMD(t, dir, "catalog", "list") + if !strings.HasPrefix(out, "No catalogs registered") { + t.Fatalf("a2a catalog list after remove = %q, want no catalogs", out) + } +} + +func TestCatalogCommandErrors(t *testing.T) { + t.Parallel() + catalogURL := startCatalogServer(t, map[string]string{"/c.json": agentCatalog()}) + + tests := []struct { + name string + args []string + wantCode string + }{ + {name: "remove unknown", args: []string{"catalog", "remove", "nope"}, wantCode: clierr.CodeUsage}, + {name: "update unknown", args: []string{"catalog", "update", "nope"}, wantCode: clierr.CodeUsage}, + {name: "search unknown catalog", args: []string{"catalog", "search", "--catalog", "nope"}, wantCode: clierr.CodeUsage}, + {name: "add without source", args: []string{"catalog", "add", "x"}, wantCode: clierr.CodeUsage}, + {name: "add missing catalog", args: []string{"catalog", "add", "x", catalogURL + "/missing.json"}, wantCode: clierr.CodeInternal}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, _, err := runCatalogCMD(t, t.TempDir(), tt.args...) + if got := clierr.Classify(err); got == nil || got.Code != tt.wantCode { + t.Fatalf("a2a %s error = %v, want code %s", strings.Join(tt.args, " "), err, tt.wantCode) + } + }) + } +} + +func TestAgentCardFromCatalog(t *testing.T) { + t.Parallel() + agentURL := startTestServer(t) + cardURL := agentURL + "/.well-known/agent-card.json" + catalogURL := startCatalogServer(t, map[string]string{ + "/corp.json": agentCatalog(agentCatalogEntry("urn:air:test:agent:echo", cardURL), agentCatalogEntry("urn:air:test:agent:other", cardURL)), + "/lab.json": agentCatalog(agentCatalogEntry("urn:air:test:agent:lab", cardURL), agentCatalogEntry("urn:air:test:agent:other", cardURL)), + "/single.json": agentCatalog(agentCatalogEntry("urn:air:test:agent:single", cardURL), `{"identifier":"urn:x","type":"application/mcp-server-card+json","url":"/x"}`), + "/inline.json": agentCatalog(fmt.Sprintf(`{"identifier":"urn:air:test:agent:inline","type":"application/a2a-agent-card+json","data":%s}`, + mustMarshalCard(t, newAgentCard(agentURL, a2a.AgentCapabilities{Streaming: true})))), + }) + dir := t.TempDir() + mustRunCatalogCMD(t, dir, "catalog", "add", "corp", catalogURL+"/corp.json") + mustRunCatalogCMD(t, dir, "catalog", "add", "lab", catalogURL+"/lab.json") + + tests := []struct { + name string + args []string + }{ + {name: "identifier across registered catalogs", args: []string{"-a", "urn:air:test:agent:echo"}}, + {name: "identifier in a registered catalog", args: []string{"--catalog", "LAB", "-a", "urn:air:test:agent:other"}}, + {name: "identifier in an unregistered catalog", args: []string{"--catalog", catalogURL + "/single.json", "-a", "urn:air:test:agent:single"}}, + {name: "catalog with a single agent", args: []string{"-a", catalogURL + "/single.json"}}, + {name: "card embedded in the catalog", args: []string{"--catalog", catalogURL + "/inline.json", "-a", "urn:air:test:agent:inline"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + out := mustRunCatalogCMD(t, dir, append([]string{"send", "-o", "json", "hello"}, tt.args...)...) + task := mustDecodeTask(t, out) + if got := testutil.AllArtifactText(task); got != "hello" { + t.Fatalf("a2a send %v artifact text = %q, want %q", tt.args, got, "hello") + } + }) + } + + t.Run("card get with positional identifier", func(t *testing.T) { + t.Parallel() + out := mustRunCatalogCMD(t, dir, "card", "get", "urn:air:test:agent:echo", "-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 card.Name = %q, want %q", card.Name, "Test Echo") + } + }) +} + +func mustMarshalCard(t *testing.T, card *a2a.AgentCard) string { + t.Helper() + data, err := json.Marshal(card) + if err != nil { + t.Fatalf("json.Marshal(card) error = %v", err) + } + return string(data) +} + +func TestAgentCardFromCatalogErrors(t *testing.T) { + t.Parallel() + catalogURL := startCatalogServer(t, map[string]string{ + "/corp.json": agentCatalog(agentCatalogEntry("urn:air:test:agent:a", "/a.json"), agentCatalogEntry("urn:air:test:agent:shared", "/s.json")), + "/lab.json": agentCatalog(agentCatalogEntry("urn:air:test:agent:shared", "/s.json")), + "/none.json": agentCatalog(`{"identifier":"urn:x","type":"application/mcp-server-card+json","url":"/x"}`), + }) + dir := t.TempDir() + mustRunCatalogCMD(t, dir, "catalog", "add", "corp", catalogURL+"/corp.json") + mustRunCatalogCMD(t, dir, "catalog", "add", "lab", catalogURL+"/lab.json") + + tests := []struct { + name string + dir string + args []string + wantCode string + wantMsg string + wantHint string + }{ + { + name: "unknown identifier lists the agents", + dir: dir, + args: []string{"-a", "urn:air:test:agent:nope"}, + wantCode: clierr.CodeCardInvalid, + wantMsg: `no A2A agent "urn:air:test:agent:nope" in the registered catalogs`, + wantHint: "A2A agents in the registered catalogs: urn:air:test:agent:a, urn:air:test:agent:shared", + }, + { + name: "no catalogs registered", + dir: t.TempDir(), + args: []string{"-a", "urn:air:test:agent:a"}, + wantCode: clierr.CodeCardInvalid, + wantHint: "a2a catalog add", + }, + { + name: "identifier in two catalogs", + dir: dir, + args: []string{"-a", "urn:air:test:agent:shared"}, + wantCode: clierr.CodeCardInvalid, + wantMsg: "listed by more than one catalog: corp, lab", + wantHint: "--catalog", + }, + { + name: "not an agent", + dir: dir, + args: []string{"--catalog", catalogURL + "/none.json", "-a", "urn:x"}, + wantCode: clierr.CodeCardInvalid, + wantMsg: "application/mcp-server-card+json entry, not an A2A agent", + }, + { + name: "catalog with several agents", + dir: dir, + args: []string{"-a", catalogURL + "/corp.json"}, + wantCode: clierr.CodeCardInvalid, + wantMsg: "AI Catalog with 2 A2A agents", + wantHint: "urn:air:test:agent:a, urn:air:test:agent:shared", + }, + { + name: "catalog with no agents", + dir: dir, + args: []string{"-a", catalogURL + "/none.json"}, + wantCode: clierr.CodeCardInvalid, + wantMsg: "AI Catalog with no A2A agents", + }, + { + name: "unknown catalog name", + dir: dir, + args: []string{"--catalog", "nope", "-a", "urn:air:test:agent:a"}, + wantCode: clierr.CodeUsage, + wantHint: "a2a catalog list", + }, + { + name: "unregistered catalog not found", + dir: dir, + args: []string{"--catalog", catalogURL + "/missing.json", "-a", "urn:air:test:agent:a"}, + wantCode: clierr.CodeCardInvalid, + wantMsg: "404", + }, + { + name: "catalog with endpoint", + dir: dir, + args: []string{"--catalog", "corp", "-e", "http://127.0.0.1:1", "--transport", "rest"}, + wantCode: clierr.CodeUsage, + }, + { + name: "catalog without agent card", + dir: dir, + args: []string{"--catalog", "corp"}, + wantCode: clierr.CodeUsage, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, _, err := runCatalogCMD(t, tt.dir, append([]string{"send", "hello"}, tt.args...)...) + var ce *clierr.Error + if !errors.As(err, &ce) { + t.Fatalf("a2a send %v error = %v, want *clierr.Error", tt.args, err) + } + if ce.Code != tt.wantCode { + t.Fatalf("a2a send %v Code = %v, want %v (error: %v)", tt.args, ce.Code, tt.wantCode, err) + } + if !strings.Contains(ce.Message, tt.wantMsg) { + t.Fatalf("a2a send %v Message = %q, want containing %q", tt.args, ce.Message, tt.wantMsg) + } + if !strings.Contains(ce.Hint, tt.wantHint) { + t.Fatalf("a2a send %v Hint = %q, want containing %q", tt.args, ce.Hint, tt.wantHint) + } + }) + } +} diff --git a/internal/cli/catalog_update.go b/internal/cli/catalog_update.go new file mode 100644 index 0000000..9191335 --- /dev/null +++ b/internal/cli/catalog_update.go @@ -0,0 +1,92 @@ +// 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 cli + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/a2aproject/a2a-cli/internal/aicatalog" +) + +func newCatalogUpdateCmd(cfg *globalConfig) *cobra.Command { + return &cobra.Command{ + Use: "update [name]", + Short: "Fetch registered AI Catalogs again", + Long: "Fetch a registered AI Catalog from its source again, or every registered catalog when no name is given.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(cmd.Context(), cfg.timeout) + defer cancel() + if err := cfg.initTLS(); err != nil { + return err + } + reg, err := cfg.openCatalogs() + if err != nil { + return err + } + var names []string + if len(args) == 1 { + names = args + } else { + for _, c := range reg.Catalogs() { + names = append(names, c.Name) + } + } + if len(names) == 0 && !cfg.IsJSON() { + _, err := io.WriteString(cfg.Out, "No catalogs registered. Add one with: a2a catalog add \n") + return err + } + + views := []catalogView{} + for _, name := range names { + res, err := reg.Update(ctx, cfg.catalogFetcher(), name) + if err != nil { + return catalogCmdError("updating", name, err) + } + if err := cfg.warnAll(res.Warnings); err != nil { + return err + } + views = append(views, newCatalogView(res.Catalog)) + if cfg.IsJSON() { + continue + } + state := "up to date" + if res.Changed { + state = fmt.Sprintf("updated (%d entries)", res.Catalog.EntryCount) + } + if _, err := fmt.Fprintf(cfg.Out, "Catalog %q %s\n", res.Catalog.Name, state); err != nil { + return err + } + } + if cfg.IsJSON() { + return cfg.PrintJSON(views) + } + return nil + }, + } +} + +// catalogCmdError classifies an error from a catalog command. +func catalogCmdError(action, name string, err error) error { + if _, ok := errors.AsType[*aicatalog.UnknownCatalogError](err); ok { + return catalogError(err) + } + return fmt.Errorf("%s catalog %q: %w", action, name, err) +} diff --git a/internal/cli/client.go b/internal/cli/client.go index c14e0fc..520aad7 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -17,6 +17,7 @@ package cli import ( "context" "crypto/tls" + "errors" "fmt" "net/http" "net/url" @@ -26,6 +27,7 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" + "github.com/a2aproject/a2a-cli/internal/aicatalog" "github.com/a2aproject/a2a-cli/internal/clierr" "github.com/a2aproject/a2a-cli/internal/flagparse" "github.com/a2aproject/a2a-cli/internal/transportplugin" @@ -55,13 +57,14 @@ func newAgentClient(ctx context.Context, cfg *globalConfig, extraOpts ...a2aclie switch { case cfg.url != "" && cfg.agentCard.IsSet(): return nil, clierr.Usage("--endpoint and --agent-card are mutually exclusive") + case cfg.url != "" && cfg.catalog != "": + return nil, clierr.Usage("--catalog applies to --agent-card, not --endpoint") case cfg.url != "": return newClientFromEndpoint(ctx, cfg, cfg.url, extraOpts...) case cfg.agentCard.IsSet(): - if err := cfg.agentCard.Validate(); err != nil { - return nil, clierr.Usage(err.Error()) - } - return newClientFromCard(ctx, cfg, cfg.agentCard.URL(), extraOpts...) + return newClientFromCard(ctx, cfg, extraOpts...) + case cfg.catalog != "": + return nil, clierr.Usage("--catalog requires --agent-card ") default: return nil, clierr.Usage("either '--agent-card ' or '--endpoint --transport ' must be provided") } @@ -94,13 +97,13 @@ func newClientFromEndpoint(ctx context.Context, cfg *globalConfig, ref string, e // newClientFromCard resolves the Agent Card and builds a client for it, honoring // --transport as an ordered client preference over the card's declared interfaces. -func newClientFromCard(ctx context.Context, cfg *globalConfig, ref string, extraOpts ...a2aclient.FactoryOption) (*a2aclient.Client, error) { +func newClientFromCard(ctx context.Context, cfg *globalConfig, extraOpts ...a2aclient.FactoryOption) (*a2aclient.Client, error) { transportPrefs, err := flagparse.Transports(cfg.transports) if err != nil { return nil, err } - card, err := resolveCard(ctx, cfg, ref) + card, err := resolveAgentCard(ctx, cfg) if err != nil { return nil, err } @@ -120,11 +123,16 @@ func newClientFromCard(ctx context.Context, cfg *globalConfig, ref string, extra return a2aclient.NewFromCard(ctx, card, factoryOpts...) } -// resolveCard fetches the agent card with every service parameter sent as a request -// header, so a gateway authenticating the card fetch sees what the transport sends. -func resolveCard(ctx context.Context, cfg *globalConfig, ref string) (*a2a.AgentCard, error) { +// errCatalogDocument marks a card request answered with an AI Catalog. +var errCatalogDocument = errors.New("the document is an AI Catalog, not an Agent Card") + +// fetchCardDocument fetches the agent card with every service parameter sent as a +// request header, so a gateway authenticating the card fetch sees what the transport +// sends. It also returns the card as served. When allowCatalog is set and ref serves +// an AI Catalog, it returns a nil card and the catalog. +func fetchCardDocument(ctx context.Context, cfg *globalConfig, ref string, allowCatalog bool) (*a2a.AgentCard, []byte, error) { if err := cfg.initTLS(); err != nil { - return nil, err + return nil, nil, err } var opts []agentcard.ResolveOption for k, vals := range cfg.svcParams.Params() { @@ -132,17 +140,30 @@ func resolveCard(ctx context.Context, cfg *globalConfig, ref string) (*a2a.Agent } headers, err := cfg.cardHeaders(ctx, ref) if err != nil { - return nil, err + return nil, nil, err } for k, vals := range headers { opts = append(opts, agentcard.WithRequestHeader(k, strings.Join(vals, ", "))) } + var raw []byte + resolver := cardResolver(cfg) + parse := resolver.CardParser + resolver.CardParser = func(body []byte) (*a2a.AgentCard, error) { + raw = body + if aicatalog.IsCatalogDocument(body) { + return nil, errCatalogDocument + } + return parse(body) + } cfg.logf("resolving agent card from %s", ref) - card, err := cardResolver(cfg).Resolve(ctx, ref, opts...) + card, err := resolver.Resolve(ctx, ref, opts...) + if allowCatalog && errors.Is(err, errCatalogDocument) { + return nil, raw, nil + } if err != nil { - return nil, clierr.CardResolution(err) + return nil, nil, clierr.CardResolution(err) } - return card, nil + return card, raw, nil } func cardResolver(cfg *globalConfig) *agentcard.Resolver { diff --git a/internal/cli/root.go b/internal/cli/root.go index 0a90bc2..9abb901 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -46,6 +46,8 @@ type deps struct { cfgLoader cfgLoaderFunc isTerminal func() bool notifyInterrupt func(context.Context) (context.Context, context.CancelFunc) + // catalogDir overrides the AI Catalog store directory. + catalogDir string } func (d *deps) setDefaults() { @@ -68,6 +70,8 @@ func (d *deps) setDefaults() { type globalConfig struct { output string agentCard flagparse.URLOrPath + catalog string + catalogDir string url string transports []string svcParams *flagparse.ServiceParams @@ -154,6 +158,7 @@ func (g *globalConfig) renderError(err error) int { func newRootCmd(cfg *globalConfig, deps deps) (*cobra.Command, error) { deps.setDefaults() + cfg.catalogDir = deps.catalogDir cmd := &cobra.Command{ Use: "a2a", @@ -203,7 +208,8 @@ func newRootCmd(cfg *globalConfig, deps deps) (*cobra.Command, error) { pf := cmd.PersistentFlags() 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.VarP(&cfg.agentCard, "agent-card", "a", "Agent Card reference: host/origin, full card URL, local file path, or an AI Catalog entry identifier") + pf.StringVar(&cfg.catalog, "catalog", "", "AI Catalog to resolve --agent-card in: a registered catalog name, or a catalog URL (experimental)") 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.") @@ -227,6 +233,7 @@ func newRootCmd(cfg *globalConfig, deps deps) (*cobra.Command, error) { newSendCmd(cfg, deps.poller), newChatCmd(cfg, deps), newTaskCmd(cfg), + newCatalogCmd(cfg), newConfigCmd(cfg), newAuthCmd(cfg), newServeCmd(cfg), diff --git a/skills/a2a-cli/SKILL.md b/skills/a2a-cli/SKILL.md index f05498d..b00438b 100644 --- a/skills/a2a-cli/SKILL.md +++ b/skills/a2a-cli/SKILL.md @@ -131,7 +131,8 @@ Run `a2a --help` for the full, current set. The load-bearing ones: | Flag | Use | |---|---| -| `-a, --agent-card ` | Resolve the agent's card (picks the transport). | +| `-a, --agent-card ` | Resolve the agent's card (picks the transport). A `urn:` is an agent identifier looked up in AI Catalogs. | +| `--catalog ` | AI Catalog to look the `-a` identifier up in (experimental). | | `-e, --endpoint ` + `--transport ` | Connect to one interface directly, skipping card resolution. | | `-o, --output json` | Machine-readable output; add `--stream` for a live event stream. | | `--async` | Return immediately with the identifiers instead of blocking; poll later with `task get`. | @@ -150,6 +151,11 @@ user-level `~/.config/a2a-cli/config.yaml`; precedence is flag > env var > local file > user `config.yaml` > global `.env`. Inspect the effective values and where each resolved from with `a2a config show` (secrets redacted). +AI Catalogs (experimental) list agents by identifier. `a2a catalog add +` registers one, `a2a catalog search [query]` finds A2A agents and their +identifiers, and `a2a send -a ` talks to one. Registered catalogs are +shared with ai-catalog-cli. + Command plugins — `a2a-` binaries on `PATH` that add top-level commands — are opt-in and disabled by default. Enable discovery with `a2a plugin set-enabled true`.