diff --git a/agent-plugin/skills/a2a-cli/SKILL.md b/agent-plugin/skills/a2a-cli/SKILL.md index ef5ae13..d301fde 100644 --- a/agent-plugin/skills/a2a-cli/SKILL.md +++ b/agent-plugin/skills/a2a-cli/SKILL.md @@ -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 @@ -128,7 +128,12 @@ Run `a2a --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-` binaries on `PATH` that add top-level commands — +are opt-in and disabled by default. Enable discovery with `a2a plugin +set-enabled true`. diff --git a/internal/README.md b/internal/README.md index 3297b0a..e8d4cf0 100644 --- a/internal/README.md +++ b/internal/README.md @@ -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 @@ -99,6 +99,15 @@ A2ACLI_TIMEOUT=60s A2ACLI_AUTH="Bearer " ``` +### 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: @@ -106,8 +115,9 @@ 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. @@ -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-` 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 diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ceda1d3..e429ed6 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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" @@ -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...) @@ -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 @@ -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 diff --git a/internal/cli/plugin.go b/internal/cli/plugin.go index f55bc1d..b5c2527 100644 --- a/internal/cli/plugin.go +++ b/internal/cli/plugin.go @@ -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 } diff --git a/internal/cli/plugin_list.go b/internal/cli/plugin_list.go index 5a98c67..80b4044 100644 --- a/internal/cli/plugin_list.go +++ b/internal/cli/plugin_list.go @@ -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. @@ -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", @@ -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) }, } } @@ -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-\" 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-\" 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) } diff --git a/internal/cli/plugin_set_enabled.go b/internal/cli/plugin_set_enabled.go new file mode 100644 index 0000000..668c4ae --- /dev/null +++ b/internal/cli/plugin_set_enabled.go @@ -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 ", + 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 + }, + } +} diff --git a/internal/cli/plugin_test.go b/internal/cli/plugin_test.go new file mode 100644 index 0000000..02eb9bc --- /dev/null +++ b/internal/cli/plugin_test.go @@ -0,0 +1,206 @@ +// 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" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/a2aproject/a2a-cli/internal/clicfg" + "github.com/a2aproject/a2a-cli/internal/flagparse" + "github.com/a2aproject/a2a-cli/internal/output" +) + +func TestPluginListStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + deps deps + args []string + wantContains []string + wantAbsent []string + }{ + { + name: "disabled by default", + deps: deps{cfgLoader: clicfg.LoadEmpty}, + args: []string{"plugin", "list"}, + wantContains: []string{"Command plugins: disabled", "set-enabled true"}, + }, + { + name: "enabled via config", + deps: deps{cfgLoader: pluginsEnabledLoader()}, + args: []string{"plugin", "list"}, + wantContains: []string{"Command plugins: enabled"}, + wantAbsent: []string{"set-enabled true"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + out, err := runCMDWithConfig(t, tc.deps, tc.args...) + if err != nil { + t.Fatalf("runCMDWithConfig(%v) error = %v", tc.args, err) + } + for _, want := range tc.wantContains { + if !strings.Contains(out, want) { + t.Fatalf("plugin list = %q, want it to contain %q", out, want) + } + } + for _, absent := range tc.wantAbsent { + if strings.Contains(out, absent) { + t.Fatalf("plugin list = %q, want it to not contain %q", out, absent) + } + } + }) + } +} + +func TestPluginSetEnabled(t *testing.T) { + tests := []struct { + name string + seed string + arg string + wantErrContains string + wantFileContains string + }{ + { + name: "enable writes true", + arg: "true", + wantFileContains: "plugins-enabled: true", + }, + { + name: "disable overwrites existing", + seed: "plugins-enabled: true\n", + arg: "false", + wantFileContains: "plugins-enabled: false", + }, + { + name: "invalid argument", + arg: "notabool", + wantErrContains: "invalid boolean", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + path := filepath.Join(home, ".config", "a2a-cli", "config.yaml") + + if tc.seed != "" { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("os.MkdirAll(%q) error = %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(tc.seed), 0o600); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", path, err) + } + } + + _, err := runCMD(t, "plugin", "set-enabled", tc.arg) + + if tc.wantErrContains != "" { + if err == nil { + t.Fatalf("runCMD(plugin set-enabled %s) error = nil, want a usage error", tc.arg) + } + if !strings.Contains(err.Error(), tc.wantErrContains) { + t.Fatalf("runCMD(plugin set-enabled %s) error = %v, want it to contain %q", tc.arg, err, tc.wantErrContains) + } + return + } + + if err != nil { + t.Fatalf("runCMD(plugin set-enabled %s) error = %v", tc.arg, err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("os.ReadFile(%q) error = %v", path, err) + } + got := string(data) + if !strings.Contains(got, tc.wantFileContains) { + t.Fatalf("config.yaml = %q, want it to contain %q", got, tc.wantFileContains) + } + }) + } +} + +func TestCommandPluginGating(t *testing.T) { + dir := t.TempDir() + cmdName := "foo" + writeTestExecutable(t, filepath.Join(dir, "a2a-"+cmdName), "#!/bin/sh\n") + t.Setenv("PATH", dir) + + tests := []struct { + name string + deps deps + wantRegistered bool + }{ + { + name: "not registered while disabled", + deps: deps{cfgLoader: clicfg.LoadEmpty}, + wantRegistered: false, + }, + { + name: "registered while enabled", + deps: deps{cfgLoader: pluginsEnabledLoader()}, + wantRegistered: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := &globalConfig{ + Printer: output.NewPrinter(io.Discard, output.ModeText), + svcParams: &flagparse.ServiceParams{}, + } + root := mustNewRoot(t, cfg, tc.deps) + + gotRegistered := slices.ContainsFunc(root.Commands(), func(cmd *cobra.Command) bool { + return cmd.Name() == cmdName + }) + if gotRegistered != tc.wantRegistered { + t.Fatalf("hasSubcommand(root, %q) = %v, want %v", cmdName, gotRegistered, tc.wantRegistered) + } + }) + } +} + +func pluginsEnabledLoader() cfgLoaderFunc { + return func(clicfg.LoadOpts) (*clicfg.Store, error) { + return clicfg.Load(clicfg.LoadOpts{ + LookupEnv: func(k string) (string, bool) { + if k == "A2ACLI_PLUGINS_ENABLED" { + return "true", true + } + return "", false + }, + }) + } +} + +func writeTestExecutable(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", path, err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index c662d24..5fe50e6 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -33,6 +33,8 @@ import ( "github.com/a2aproject/a2a-cli/internal/polling" ) +const pluginsEnabledKey = "plugins-enabled" + type cfgLoaderFunc func(clicfg.LoadOpts) (*clicfg.Store, error) type deps struct { @@ -62,6 +64,8 @@ type globalConfig struct { insecureGRPC bool configPath string + pluginsEnabled bool + bindings []clicfg.FlagBinding errOut io.Writer @@ -88,7 +92,10 @@ func Execute() int { Printer: &output.Printer{Out: os.Stdout}, svcParams: &flagparse.ServiceParams{}, } - root := newRootCmd(cfg, deps{}) + root, err := newRootCmd(cfg, deps{}) + if err != nil { + return cfg.renderError(err) + } if err := root.Execute(); err != nil { return cfg.renderError(err) } @@ -110,7 +117,7 @@ func (g *globalConfig) renderError(err error) int { return ce.Exit } -func newRootCmd(cfg *globalConfig, deps deps) *cobra.Command { +func newRootCmd(cfg *globalConfig, deps deps) (*cobra.Command, error) { deps.setDefaults() cmd := &cobra.Command{ @@ -172,12 +179,19 @@ func newRootCmd(cfg *globalConfig, deps deps) *cobra.Command { newVersionCmd(cfg), ) - addCommandPlugins(cmd) + preStore, err := deps.cfgLoader(clicfg.LoadOpts{}) + if err != nil { + return nil, err + } + if preStore.LookupBool(pluginsEnabledKey) { + cfg.pluginsEnabled = true + addCommandPlugins(cmd) + } cmd.SetUsageTemplate(rootUsageTemplate) markUsageErrors(cmd) - return cmd + return cmd, nil } // addCommandPlugins discovers command plugin binaries on PATH and registers diff --git a/internal/cli/transport_list.go b/internal/cli/transport_list.go index 012b4b1..79813c1 100644 --- a/internal/cli/transport_list.go +++ b/internal/cli/transport_list.go @@ -15,12 +15,11 @@ package cli import ( - "fmt" "io" - "text/tabwriter" "github.com/spf13/cobra" + "github.com/a2aproject/a2a-cli/internal/output" "github.com/a2aproject/a2a-cli/internal/transportplugin" ) @@ -44,7 +43,7 @@ func newTransportListCmd(cfg *globalConfig) *cobra.Command { if cfg.IsJSON() { return cfg.PrintJSON(entries) } - return printTransportTable(cfg.Out, entries) + return printTransportTable(cfg.Printer, entries) }, } return cmd @@ -68,26 +67,20 @@ func collectTransportEntries(cmd *cobra.Command) []transportEntry { return entries } -func printTransportTable(out io.Writer, entries []transportEntry) error { +func printTransportTable(p *output.Printer, entries []transportEntry) error { if len(entries) == 0 { - _, err := io.WriteString(out, "No transport plugins found on PATH.\nInstall one by placing an \"a2a-transport-\" binary on your PATH.\n") - return err - } - - tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) - if _, err := io.WriteString(tw, "NAME\tVERSION\tPROTOCOL\tDESCRIPTION\tPATH\n"); err != nil { + _, err := io.WriteString(p.Out, "No transport plugins found on PATH.\nInstall one by placing an \"a2a-transport-\" 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\t%s\n", e.Name, dashIfEmpty(e.Version), dashIfEmpty(e.Protocol), dashIfEmpty(desc), e.Path); err != nil { - return err - } + rows = append(rows, []string{e.Name, dashIfEmpty(e.Version), dashIfEmpty(e.Protocol), dashIfEmpty(desc), e.Path}) } - return tw.Flush() + return p.PrintTable([]string{"NAME", "VERSION", "PROTOCOL", "DESCRIPTION", "PATH"}, rows) } func dashIfEmpty(s string) string { diff --git a/internal/clicfg/doc.go b/internal/clicfg/doc.go index 4876c73..b0ed5fb 100644 --- a/internal/clicfg/doc.go +++ b/internal/clicfg/doc.go @@ -12,6 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package clicfg resolves a2a-cli configuration from environment variables -// and .env (dotenv) files. +// Package clicfg resolves a2a-cli configuration from environment variables, +// .env (dotenv) files, and a user-level YAML config file. +// +// Values are resolved with the following precedence (highest to lowest): +// real environment variable, local .env or --config file, user-level +// config.yaml (~/.config/a2a-cli/config.yaml), and global .env +// (~/.config/a2a-cli/.env). package clicfg diff --git a/internal/clicfg/loader.go b/internal/clicfg/loader.go index 554e63f..5d9756f 100644 --- a/internal/clicfg/loader.go +++ b/internal/clicfg/loader.go @@ -32,6 +32,8 @@ type LoadOpts struct { ConfigPath string // WorkingDir is where the walk-up search for a local .env begins. Defaults to cwd. WorkingDir string + // UserPath is the user-level YAML config location. Defaults to ~/.config/a2a-cli/config.yaml. + UserPath string // GlobalPath is the global .env location. Defaults to ~/.config/a2a-cli/.env. GlobalPath string // LookupEnv reads an environment variable. Defaults to os.LookupEnv. @@ -57,6 +59,12 @@ func Load(opts LoadOpts) (*Store, error) { } store.local = local + user, err := loadUser(opts) + if err != nil { + return nil, err + } + store.user = user + global, err := loadGlobal(opts) if err != nil { return nil, err @@ -66,7 +74,10 @@ func Load(opts LoadOpts) (*Store, error) { return store, nil } -const configFileName = ".env" +const ( + configFileName = ".env" + userConfigFileName = "config.yaml" +) func loadLocal(opts LoadOpts) (*loadedFile, error) { if opts.ConfigPath != "" { @@ -113,6 +124,33 @@ func loadGlobal(opts LoadOpts) (*loadedFile, error) { return &loadedFile{path: path, values: toStringAnyMap(values)}, nil } +func loadUser(opts LoadOpts) (*loadedFile, error) { + path := opts.UserPath + if path == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, nil + } + path = filepath.Join(home, ".config", "a2a-cli", userConfigFileName) + } + + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + values, err := loadYAML(path) + if err != nil { + return nil, err + } + if values == nil { + return nil, nil + } + return &loadedFile{path: path, values: values}, nil +} + func loadConfigFile(path string) (map[string]any, error) { ext := strings.ToLower(filepath.Ext(path)) switch ext { diff --git a/internal/clicfg/store.go b/internal/clicfg/store.go index c4cd7c8..ac67ce4 100644 --- a/internal/clicfg/store.go +++ b/internal/clicfg/store.go @@ -16,6 +16,7 @@ package clicfg import ( "fmt" + "strconv" "strings" ) @@ -27,6 +28,8 @@ const ( SourceEnv SourceKind = "env" // SourceLocalFile is the local .env (or the file named by --config). SourceLocalFile = "local-file" + // SourceUserFile is the user-level config.yaml under ~/.config/a2a-cli. + SourceUserFile = "user-file" // SourceGlobalFile is the global .env under ~/.config/a2a-cli. SourceGlobalFile = "global-file" ) @@ -44,10 +47,11 @@ func (s Source) String() string { return string(s.Kind) } -// Store holds the configuration resolved from the environment and .env files. +// Store holds the configuration resolved from the environment and config files. type Store struct { lookupEnv func(string) (string, bool) local *loadedFile + user *loadedFile global *loadedFile } @@ -60,6 +64,17 @@ func (s *Store) Lookup(key string) (string, Source, bool) { return formatValue(val), src, true } +// LookupBool returns true is config has a value matching [strconv.ParseBool] accepted +// truth literals and false otherwise. +func (s *Store) LookupBool(key string) bool { + val, _, ok := s.lookupValue(key) + if !ok { + return false + } + enabled, err := strconv.ParseBool(strings.TrimSpace(formatValue(val))) + return err == nil && enabled +} + func (s *Store) lookupValue(key string) (any, Source, bool) { if flagName, ok := envVarToFlag(key); ok { return s.LookupFlag(flagName, key) @@ -80,6 +95,11 @@ func (s *Store) LookupFlag(name, envVar string) (any, Source, bool) { return v, Source{Kind: SourceLocalFile, Path: s.local.path}, true } } + if s.user != nil { + if v, ok := s.user.lookup(name, envVar); ok { + return v, Source{Kind: SourceUserFile, Path: s.user.path}, true + } + } if s.global != nil { if v, ok := s.global.lookup(name, envVar); ok { return v, Source{Kind: SourceGlobalFile, Path: s.global.path}, true diff --git a/internal/clicfg/userconfig.go b/internal/clicfg/userconfig.go new file mode 100644 index 0000000..501abf0 --- /dev/null +++ b/internal/clicfg/userconfig.go @@ -0,0 +1,62 @@ +// 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 clicfg + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// DefaultUserConfigPath returns the path to the user-level YAML config file. +func DefaultUserConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolving home directory: %w", err) + } + return filepath.Join(home, ".config", "a2a-cli", userConfigFileName), nil +} + +// SetUserValue reads the user config at path, sets key to value, and writes it +// back, preserving other keys and creating parent directories as needed. +func SetUserValue(path, key string, value any) error { + values := map[string]any{} + if _, err := os.Stat(path); err == nil { + existing, err := loadYAML(path) + if err != nil { + return fmt.Errorf("reading user config %q: %w", path, err) + } + if existing != nil { + values = existing + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("reading user config %q: %w", path, err) + } + values[key] = value + + data, err := yaml.Marshal(values) + if err != nil { + return fmt.Errorf("encoding user config: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("creating config directory: %w", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("writing user config %q: %w", path, err) + } + return nil +} diff --git a/internal/clicfg/userconfig_test.go b/internal/clicfg/userconfig_test.go new file mode 100644 index 0000000..29aa9ed --- /dev/null +++ b/internal/clicfg/userconfig_test.go @@ -0,0 +1,170 @@ +// 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 clicfg + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadUserConfigPrecedence(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + env map[string]string + local map[string]string + user string + global map[string]string + wantValue string + wantKind SourceKind + }{ + { + name: "user file loaded", + user: "plugins-enabled: true\n", + wantValue: "true", + wantKind: SourceUserFile, + }, + { + name: "local wins over user", + local: map[string]string{"A2ACLI_PLUGINS_ENABLED": "false"}, + user: "plugins-enabled: true\n", + wantValue: "false", + wantKind: SourceLocalFile, + }, + { + name: "user wins over global", + user: "plugins-enabled: true\n", + global: map[string]string{"A2ACLI_PLUGINS_ENABLED": "false"}, + wantValue: "true", + wantKind: SourceUserFile, + }, + { + name: "env wins over user", + env: map[string]string{"A2ACLI_PLUGINS_ENABLED": "false"}, + user: "plugins-enabled: true\n", + wantValue: "false", + wantKind: SourceEnv, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + opts := LoadOpts{WorkingDir: dir, LookupEnv: makeEnv(tc.env)} + if tc.local != nil { + writeDotenv(t, filepath.Join(dir, ".env"), tc.local) + } + if tc.user != "" { + opts.UserPath = filepath.Join(dir, "config.yaml") + if err := os.WriteFile(opts.UserPath, []byte(tc.user), 0o600); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", opts.UserPath, err) + } + } + if tc.global != nil { + opts.GlobalPath = filepath.Join(dir, ".env.global") + writeDotenv(t, opts.GlobalPath, tc.global) + } + + store, err := Load(opts) + if err != nil { + t.Fatalf("Load() error = %v, want nil", err) + } + + value, source, ok := store.Lookup("plugins-enabled") + if !ok { + t.Fatalf("store.Lookup(%q) ok = false, want true", "plugins-enabled") + } + if value != tc.wantValue { + t.Errorf("store.Lookup(%q) = %v, want %v", "plugins-enabled", value, tc.wantValue) + } + if source.Kind != tc.wantKind { + t.Errorf("store.Lookup(%q) source.Kind = %v, want %v", "plugins-enabled", source.Kind, tc.wantKind) + } + }) + } +} + +func TestLoadUserConfigMissingIsNotAnError(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + store, err := Load(LoadOpts{ + WorkingDir: dir, + UserPath: filepath.Join(dir, "config.yaml"), + GlobalPath: filepath.Join(dir, ".env.global"), + LookupEnv: makeEnv(nil), + }) + if err != nil { + t.Fatalf("Load() error = %v, want nil", err) + } + if _, _, ok := store.Lookup("plugins-enabled"); ok { + t.Fatalf("store.Lookup(%q) ok = true, want false", "plugins-enabled") + } +} + +func TestSetUserValueRoundTrip(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "nested", "config.yaml") + + if err := SetUserValue(path, "plugins-enabled", true); err != nil { + t.Fatalf("SetUserValue() error = %v, want nil", err) + } + + store, err := Load(LoadOpts{WorkingDir: dir, UserPath: path, LookupEnv: makeEnv(nil)}) + if err != nil { + t.Fatalf("Load() error = %v, want nil", err) + } + value, source, ok := store.Lookup("plugins-enabled") + if !ok { + t.Fatalf("store.Lookup(%q) ok = false, want true", "plugins-enabled") + } + if value != "true" { + t.Errorf("store.Lookup(%q) = %v, want %v", "plugins-enabled", value, "true") + } + if source.Kind != SourceUserFile { + t.Errorf("store.Lookup(%q) source.Kind = %v, want %v", "plugins-enabled", source.Kind, SourceUserFile) + } +} + +func TestSetUserValuePreservesExistingKeys(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte("agent-card: https://kept.example.com\n"), 0o600); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", path, err) + } + + if err := SetUserValue(path, "plugins-enabled", true); err != nil { + t.Fatalf("SetUserValue() error = %v, want nil", err) + } + + store, err := Load(LoadOpts{WorkingDir: dir, UserPath: path, LookupEnv: makeEnv(nil)}) + if err != nil { + t.Fatalf("Load() error = %v, want nil", err) + } + if value, _, ok := store.Lookup("agent-card"); !ok || value != "https://kept.example.com" { + t.Fatalf("store.Lookup(%q) = (%v, %v), want (%v, true)", "agent-card", value, ok, "https://kept.example.com") + } + if value, _, ok := store.Lookup("plugins-enabled"); !ok || value != "true" { + t.Fatalf("store.Lookup(%q) = (%v, %v), want (%v, true)", "plugins-enabled", value, ok, "true") + } +} diff --git a/internal/output/output.go b/internal/output/output.go index 5d51715..4b94a3b 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -84,6 +84,20 @@ func (p *Printer) PrintJSON(v any) error { return p.jsonEncoder().Encode(v) } +// PrintTable writes rows as a column-aligned text table under headers. +func (p *Printer) PrintTable(headers []string, rows [][]string) error { + tw := tabwriter.NewWriter(p.Out, 0, 4, 2, ' ', 0) + if _, err := io.WriteString(tw, strings.Join(headers, "\t")+"\n"); err != nil { + return err + } + for _, row := range rows { + if _, err := io.WriteString(tw, strings.Join(row, "\t")+"\n"); err != nil { + return err + } + } + return tw.Flush() +} + // PrintCard writes an agent card in the configured Mode. func (p *Printer) PrintCard(card *a2a.AgentCard) error { if p.IsJSON() { diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 396bc34..d41a1f8 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -15,6 +15,7 @@ package output import ( + "bytes" "testing" "github.com/google/go-cmp/cmp" @@ -22,6 +23,49 @@ import ( "github.com/a2aproject/a2a-go/v2/a2a" ) +func TestPrintTable(t *testing.T) { + t.Parallel() + + headers := []string{"NAME", "VERSION", "PATH"} + + tests := []struct { + name string + rows [][]string + want string + }{ + { + name: "aligns headers and rows into columns", + rows: [][]string{ + {"foo", "1.0.0", "/bin/foo"}, + {"barbar", "-", "/usr/local/bin/barbar"}, + }, + want: "NAME VERSION PATH\n" + + "foo 1.0.0 /bin/foo\n" + + "barbar - /usr/local/bin/barbar\n", + }, + { + name: "no rows writes only the header", + rows: nil, + want: "NAME VERSION PATH\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + p := NewPrinter(&buf, ModeText) + if err := p.PrintTable(headers, tt.rows); err != nil { + t.Fatalf("PrintTable() error = %v, want nil", err) + } + if diff := cmp.Diff(tt.want, buf.String()); diff != "" { + t.Fatalf("PrintTable() wrong result (-want +got) diff = %s", diff) + } + }) + } +} + func TestMessageTextFileParts(t *testing.T) { t.Parallel() diff --git a/skills/a2a-cli/SKILL.md b/skills/a2a-cli/SKILL.md index 5649d27..92f9495 100644 --- a/skills/a2a-cli/SKILL.md +++ b/skills/a2a-cli/SKILL.md @@ -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 @@ -129,7 +129,12 @@ Run `a2a --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-` binaries on `PATH` that add top-level commands — +are opt-in and disabled by default. Enable discovery with `a2a plugin +set-enabled true`.