Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions agent-plugin/skills/a2a-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ compatibility: >-
license: Apache-2.0
metadata:
source: https://github.com/a2aproject/a2a-cli
version: "2026.09.08"
version: "2026.09.22"
---

# Driving A2A agents with the `a2a` CLI
Expand Down Expand Up @@ -128,7 +128,12 @@ Run `a2a <command> --help` for the full, current set. The load-bearing ones:

## Configuration

Every setting can come from a flag, an `A2ACLI_*` environment variable, or a
`.env` file (a local `.env`, or `~/.config/a2a-cli/.env`); precedence is
flag > env var > file. Inspect the effective values and where each resolved from
with `a2a config show` (secrets redacted).
Every setting can come from a flag, an `A2ACLI_*` environment variable, a
`.env` file (a local `.env`, or `~/.config/a2a-cli/.env`), or the persistent
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).

Command plugins — `a2a-<name>` binaries on `PATH` that add top-level commands —
are opt-in and disabled by default. Enable discovery with `a2a plugin
set-enabled true`.
39 changes: 36 additions & 3 deletions internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ These apply to every client-mode command. Each command selects the agent it talk

## Configuration

Every global default can be set from the environment, an explicit configuration file (`.yaml`, `.json`), or a `.env` file. This keeps repeated invocations short.
Every global default can be set from the environment, an explicit configuration file (`.yaml`, `.json`), a `.env` file, or a persistent user-level `config.yaml`. This keeps repeated invocations short.

### YAML and JSON Configuration

Expand Down Expand Up @@ -99,15 +99,25 @@ A2ACLI_TIMEOUT=60s
A2ACLI_AUTH="Bearer <token>"
```

### User-level config file

A persistent user-level config lives at `~/.config/a2a-cli/config.yaml`. It uses the same kebab-case flag names and native YAML types as a `--config` file, but applies to every invocation without being passed explicitly. It is also where CLI-managed settings are stored — for example `plugins-enabled`, written by `a2a plugin set-enabled` (see [Command Plugins](#command-plugins)).

```yaml
plugins-enabled: true
tenant: my-team
```

### Precedence

When the same setting is defined in multiple places, the first match wins:

1. an explicit command-line flag,
2. a session environment variable (`A2ACLI_*`),
3. a local configuration file (the file named by `--config`, or the nearest `.env` found by walking up from the working directory),
4. the global `.env` at `$XDG_CONFIG_HOME/a2a-cli/.env` (default `~/.config/a2a-cli/.env`),
5. the built-in flag default.
4. the user-level `config.yaml` (default `~/.config/a2a-cli/config.yaml`),
5. the global `.env` at `$XDG_CONFIG_HOME/a2a-cli/.env` (default `~/.config/a2a-cli/.env`),
6. the built-in flag default.

`--stream`, `--help`, `--version`, and `--config` are never read from configuration files or the environment and must be passed explicitly.

Expand Down Expand Up @@ -359,6 +369,29 @@ Text mode is the default, meant for reading in a terminal. The output format con
only presentation (indentation); `--stream` independently controls whether the command
follows the agent's live events or waits for the terminal result.

## Command Plugins

The CLI can be extended with new top-level commands by installing an
`a2a-<name>` binary on your `PATH`. Discovery is **opt-in** and disabled by
default — plugins are only scanned and registered when command plugins are
enabled, so an untrusted binary on your `PATH` is never exec'd implicitly.

```console
# Enable discovery (writes plugins-enabled: true to ~/.config/a2a-cli/config.yaml)
$ a2a plugin set-enabled true

# List discovered plugins along with the current enabled/disabled state
$ a2a plugin list

# Disable again
$ a2a plugin set-enabled false
```

The `plugins-enabled` flag resolves through the same configuration chain as every
other setting, so it can also be set through the environment `A2ACLI_PLUGINS_ENABLED=true`.
See the **[command plugin guide](./docs/command-plugins.md)** for the plugin contract and
authoring details.

## Custom Transport Plugins

The CLI speaks JSON-RPC, REST and gRPC out of the box. Additional transport
Expand Down
14 changes: 12 additions & 2 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"time"

"github.com/google/go-cmp/cmp"
"github.com/spf13/cobra"
"github.com/spf13/pflag"

"github.com/a2aproject/a2a-cli/internal/clicfg"
Expand Down Expand Up @@ -1069,6 +1070,15 @@ func mustRunCMD(t *testing.T, args ...string) string {
return r
}

func mustNewRoot(t *testing.T, cfg *globalConfig, deps deps) *cobra.Command {
t.Helper()
r, err := newRootCmd(cfg, deps)
if err != nil {
t.Fatalf("newRootCmd() error = %v", err)
}
return r
}

func runCMD(t *testing.T, args ...string) (string, error) {
t.Helper()
return runCMDWithPoller(t, deps{poller: polling.Stream, cfgLoader: clicfg.LoadEmpty}, args...)
Expand All @@ -1086,7 +1096,7 @@ func runCMDWithConfig(t *testing.T, deps deps, args ...string) (string, error) {
Printer: output.NewPrinter(&buf, output.ModeText),
svcParams: &flagparse.ServiceParams{},
}
root := newRootCmd(cfg, deps)
root := mustNewRoot(t, cfg, deps)
root.SetArgs(args)
err := root.Execute()
return buf.String(), err
Expand All @@ -1100,7 +1110,7 @@ func runCMDCapturingStderr(t *testing.T, args ...string) (stdout, stderr string,
svcParams: &flagparse.ServiceParams{},
errOut: &errBuf,
}
root := newRootCmd(cfg, deps{poller: polling.Stream, cfgLoader: clicfg.LoadEmpty})
root := mustNewRoot(t, cfg, deps{poller: polling.Stream, cfgLoader: clicfg.LoadEmpty})
root.SetArgs(args)
err = root.Execute()
return out.String(), errBuf.String(), err
Expand Down
6 changes: 4 additions & 2 deletions internal/cli/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import (

func newPluginCmd(cfg *globalConfig) *cobra.Command {
cmd := &cobra.Command{
Use: "plugin",
Short: "Work with command plugins",
Use: "plugin",
Aliases: []string{"plugins"},
Short: "Work with command plugins",
}
cmd.AddCommand(
newPluginListCmd(cfg),
newPluginSetEnabledCmd(cfg),
)
return cmd
}
37 changes: 25 additions & 12 deletions internal/cli/plugin_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ package cli
import (
"fmt"
"io"
"text/tabwriter"

"github.com/spf13/cobra"

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

// pluginEntry is the JSON/text view of a discovered command plugin.
Expand All @@ -33,6 +33,12 @@ type pluginEntry struct {
Error string `json:"error,omitempty"`
}

// pluginListView is the JSON view of the plugin list, including the current state.
type pluginListView struct {
Enabled bool `json:"enabled"`
Plugins []pluginEntry `json:"plugins"`
}

func newPluginListCmd(cfg *globalConfig) *cobra.Command {
return &cobra.Command{
Use: "list",
Expand All @@ -41,9 +47,9 @@ func newPluginListCmd(cfg *globalConfig) *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
entries := collectPluginEntries(cmd)
if cfg.IsJSON() {
return cfg.PrintJSON(entries)
return cfg.PrintJSON(pluginListView{Enabled: cfg.pluginsEnabled, Plugins: entries})
}
return printPluginTable(cfg.Out, entries)
return printPluginList(cfg.Printer, cfg.pluginsEnabled, entries)
},
}
}
Expand All @@ -65,24 +71,31 @@ func collectPluginEntries(cmd *cobra.Command) []pluginEntry {
return entries
}

func printPluginTable(out io.Writer, entries []pluginEntry) error {
if len(entries) == 0 {
_, err := io.WriteString(out, "No command plugins found on PATH.\nInstall one by placing an \"a2a-<name>\" binary on your PATH.\n")
func printPluginList(p *output.Printer, enabled bool, entries []pluginEntry) error {
state := "disabled"
if enabled {
state = "enabled"
}
if _, err := fmt.Fprintf(p.Out, "Command plugins: %s\n", state); err != nil {
return err
}
if !enabled {
if _, err := io.WriteString(p.Out, "Plugins are not loaded while disabled. Enable with: a2a plugin set-enabled true\n"); err != nil {
return err
}
}

tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
if _, err := io.WriteString(tw, "NAME\tVERSION\tDESCRIPTION\tPATH\n"); err != nil {
if len(entries) == 0 {
_, err := io.WriteString(p.Out, "No command plugins found on PATH.\nInstall one by placing an \"a2a-<name>\" binary on your PATH.\n")
return err
}
rows := make([][]string, 0, len(entries))
for _, e := range entries {
desc := e.Description
if e.Error != "" {
desc = "(error: " + e.Error + ")"
}
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", e.Name, dashIfEmpty(e.Version), dashIfEmpty(desc), e.Path); err != nil {
return err
}
rows = append(rows, []string{e.Name, dashIfEmpty(e.Version), dashIfEmpty(desc), e.Path})
}
return tw.Flush()
return p.PrintTable([]string{"NAME", "VERSION", "DESCRIPTION", "PATH"}, rows)
}
63 changes: 63 additions & 0 deletions internal/cli/plugin_set_enabled.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 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"
"strconv"

"github.com/spf13/cobra"

"github.com/a2aproject/a2a-cli/internal/clicfg"
"github.com/a2aproject/a2a-cli/internal/clierr"
)

func newPluginSetEnabledCmd(cfg *globalConfig) *cobra.Command {
return &cobra.Command{
Use: "set-enabled <true|false>",
Short: "Enable or disable command plugins in the user config",
Long: "Enable or disable discovery and loading of command plugins.\n\n" +
"The setting is written to the user config file (~/.config/a2a-cli/config.yaml) " +
"as plugins-enabled and applies to every invocation until changed.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
enabled, err := strconv.ParseBool(args[0])
if err != nil {
return clierr.Usage(fmt.Sprintf("invalid boolean %q: want true or false", args[0]))
}

path, err := clicfg.DefaultUserConfigPath()
if err != nil {
return err
}
if err := clicfg.SetUserValue(path, pluginsEnabledKey, enabled); err != nil {
return err
}

if cfg.IsJSON() {
return cfg.PrintJSON(map[string]any{
"plugins-enabled": enabled,
"path": path,
})
}
state := "disabled"
if enabled {
state = "enabled"
}
_, err = fmt.Fprintf(cfg.Out, "Command plugins %s (%s in %s)\n", state, pluginsEnabledKey, path)
return err
},
}
}
Loading
Loading