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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Please choose versions by [Semantic Versioning](http://semver.org/).
* MINOR version when you add functionality in a backwards-compatible manner, and
* PATCH version when you make backwards-compatible bug fixes.

## Unreleased

- feat: **A vault can name its default A2A cluster.** A config entry gains `a2a_default_cluster`, a cluster **name** (`nukedev`, never a target string `k8s:nukedev`), set with `vault-cli config set-a2a-default-cluster <vault> <cluster>` and emitted in `config list --output json` so a consumer can resolve it without parsing the YAML. The value is validated on write as an RFC 1123 label (lowercase alphanumerics and interior hyphens, at most 63 characters), which is the shape a Kubernetes cluster identifier takes — an unknown-but-well-formed name is accepted here and refused by the consumer, because the cluster set is that side's knowledge, not this repo's. ⚠️ **Reading never validates**: a hand-edited malformed value still lists, emitted verbatim, because refusing it would turn a config typo into an unopenable vault. ⚠️ **Both tags are `omitempty`**, so a vault that names no default round-trips unchanged and `scenarios/001-config-list.md`'s exhaustive key set for the `minimal` vault still holds. A refused value or an unknown vault writes nothing — the config file is byte-identical afterwards. Documented in `docs/a2a-default-cluster.md`.

## v0.176.2

- fix: `vault-cli task set <task> claude_session_id <new>` appends the displaced owner to `metrics_sessions` before writing the new id, so a session handed off by a take-over stays resolvable by `session-self-exit.py` and `close-worker.py`. A first bind and a re-bind to the same value append nothing; the completion and spawn-failure clears are unchanged.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,20 @@ vault-cli search "improve performance" --top-k 10 # Return more results
vault-cli config list # List configured vaults
vault-cli config current-user # Print the current user
vault-cli config set-baseline personal "60 Baseline.md" # Point a vault at its baseline file
vault-cli config set-a2a-default-cluster personal nukedev # Name the cluster this vault's A2A workers default to
```

The path passed to `config set-baseline` is vault-relative — an absolute path, or
one whose cleaned form escapes the vault root, is refused. The value is written
to the named vault's entry in the config file under the key `baseline`;
`docs/baseline-file.md` documents the file's frontmatter contract.

`config set-a2a-default-cluster` takes a cluster **name** (`nukedev`, not
`k8s:nukedev`) and writes it under the key `a2a_default_cluster`, which
`config list --output json` then emits for consumers. The name is validated as an
RFC 1123 label on write and never on read; `docs/a2a-default-cluster.md` documents
the contract.

### filename

```bash
Expand Down
83 changes: 83 additions & 0 deletions docs/a2a-default-cluster.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# A2A Default Cluster

A vault's config entry can name the cluster that an A2A worker for that vault
falls back to when the task carries no explicit spawn target. The value is
stored under the key `a2a_default_cluster` and set with:

```
vault-cli config set-a2a-default-cluster <vault> <cluster>
```

The value is a cluster **name**, not a spawn target: `nukedev`, never
`k8s:nukedev`. The consumer forms the target from the name, so a value
containing a colon is refused rather than passed through.

## What this repo does and does not do

This repo stores a default, validates it on write, and emits it. It does not
consume it and does not enforce anything.

- **Consuming it** — resolving an absent spawn target to the vault default,
forming `k8s:<name>`, and refusing an unknown cluster — belongs to the
consumer (`claude-supervisor`'s `open-a2a` call site).
- **Enforcing an allowlist** — which clusters a vault *may* use — is a
supervisor-side fact. This repo records a default, not a permission.
- **The cluster-name set is not frozen.** `nukedev`, `nukeprod`, `octopus-dev`
and the rest are today's names. This repo validates the shape only, so an
unknown-but-well-formed name is accepted here and refused by the consumer.
- **Setting the per-vault values** is an operator act performed on the host
against the real config file, not something this repo seeds.

## Validation

The value is validated as an **RFC 1123 label**: lowercase alphanumerics and
interior hyphens, starting and ending with an alphanumeric, at most 63
characters — the shape a Kubernetes cluster identifier takes.

Accepted: `nukedev`, `nukeprod`, `octopus-dev`, `my-cluster-2`, a single
character, and any name up to 63 characters.

Refused: a target string (`k8s:nukedev`), uppercase (`Nukedev`), an underscore
(`nuke_dev`), a space, a dot, a slash, a non-ASCII name, a leading or trailing
hyphen, a 64-character name, and the empty string. A refused value or an unknown
vault writes nothing at all — the config file is byte-identical afterwards.

## Read-time tolerance

**Reading never validates.** A value that is already on disk is emitted
verbatim, whatever it looks like — `config list --output json` still exits 0
for a hand-edited `a2a_default_cluster: "not a cluster!"`.

This is deliberate: refusing a malformed value at read time would turn a config
typo into an unopenable vault, and every command that loads the config would
fail. An unknown cluster is the consumer's to refuse, never this repo's list
path.

## Where it lives

- The on-disk key is `a2a_default_cluster` in the vault-cli config file
(`~/.config/vault-cli/config.yaml` by default).
- The JSON spelling is the same string, and it is what a consumer reads from
`config list --output json`. Both spellings are part of the contract: the
JSON one is the delivery surface, the YAML one is the on-disk surface.
- **Both tags are `omitempty`.** A vault that names no default round-trips
unchanged, and the key appears only on the vault it was set for. Without the
omission, re-serialization would inject an empty key into every vault.

## What a write does to the file

A successful write re-serializes the whole file from the config struct. Other
keys **the config struct models** survive; comments and any key the struct does
not model do not, and keys are reordered to struct order. This is the existing
`config set-baseline` behaviour, accepted here rather than re-engineered.

The write is atomic: a temp file in the config's own directory is renamed over
it, so a reader observes either the old content or the new content and never a
truncated merge. An interrupted write leaves no temp file behind.

## Related

- `docs/baseline-file.md` — the sibling config-key plus set-verb plus doc-page
shape this one follows.
- The consumer side lives in `bborbe/claude-supervisor`'s
`commands/open-a2a.md` and `docs/fleet-surface.md`.
1 change: 1 addition & 0 deletions integration/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,7 @@ var _ = Describe("vault-cli integration tests", func() {
Entry("config list", "config", "list"),
Entry("config current-user", "config", "current-user"),
Entry("config set-baseline", "config", "set-baseline"),
Entry("config set-a2a-default-cluster", "config", "set-a2a-default-cluster"),
)
})

Expand Down
1 change: 1 addition & 0 deletions pkg/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ func NewRootCommand(ctx context.Context) *cobra.Command {
configCmd.AddCommand(createConfigListCommand(ctx, &configLoader, &vaultName, &outputFormat))
configCmd.AddCommand(createConfigCurrentUserCommand(ctx, &configLoader))
configCmd.AddCommand(createConfigSetBaselineCommand(ctx, &configPath))
configCmd.AddCommand(createConfigSetA2ADefaultClusterCommand(ctx, &configPath))
rootCmd.AddCommand(configCmd)

return rootCmd
Expand Down
22 changes: 22 additions & 0 deletions pkg/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,25 @@ func createConfigSetBaselineCommand(
},
}
}

// createConfigSetA2ADefaultClusterCommand returns the "config
// set-a2a-default-cluster" leaf command. It writes the cluster NAME a vault's
// A2A spawns fall back to onto that vault's config entry, so a consumer reading
// `config list --output json` can resolve the default without parsing the YAML.
func createConfigSetA2ADefaultClusterCommand(
ctx context.Context,
configPath *string,
) *cobra.Command {
return &cobra.Command{
Use: "set-a2a-default-cluster <vault> <cluster>",
Short: "Set a vault's default A2A cluster name",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
writer := config.NewA2ADefaultClusterWriter(*configPath)
if err := writer.SetA2ADefaultCluster(ctx, args[0], args[1]); err != nil {
return errors.Wrap(ctx, err, "set a2a default cluster")
}
return nil
},
}
}
167 changes: 167 additions & 0 deletions pkg/cli/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ package cli_test
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
Expand Down Expand Up @@ -109,3 +111,168 @@ var _ = Describe("vault-cli config set-baseline", func() {
Expect(err).NotTo(BeNil())
})
})

var _ = Describe("vault-cli config set-a2a-default-cluster", func() {
var (
ctx context.Context
tempDir string
vaultDir string
configPath string
)

readBytes := func(path string) []byte {
b, err := os.ReadFile(path)
Expect(err).To(BeNil())
return b
}

captureStdout := func(run func() error) (string, error) {
origStdout := os.Stdout
r, w, err := os.Pipe()
Expect(err).To(BeNil())
os.Stdout = w
DeferCleanup(func() {
os.Stdout = origStdout
})
runErr := run()
Expect(w.Close()).To(Succeed())
out, err := io.ReadAll(r)
Expect(err).To(BeNil())
return string(out), runErr
}

BeforeEach(func() {
ctx = context.Background()
var err error
tempDir, err = os.MkdirTemp("", "vault-cli-set-a2a-default-cluster-test-*")
Expect(err).To(BeNil())

vaultDir = filepath.Join(tempDir, "vault")
Expect(os.MkdirAll(vaultDir, 0750)).To(Succeed())

configPath = filepath.Join(tempDir, "config.yaml")
configData := fmt.Sprintf(`current_user: user@example.com
vaults:
test:
name: test
path: %s
tasks_dir: Tasks
other:
name: other
path: %s
tasks_dir: "25 Tasks"
`, vaultDir, vaultDir)
Expect(os.WriteFile(configPath, []byte(configData), 0600)).To(Succeed())
})

AfterEach(func() {
_ = os.RemoveAll(tempDir)
})

It("persists the cluster name for a named vault", func() {
err := cli.Run(
ctx,
[]string{"--config", configPath, "config", "set-a2a-default-cluster", "test", "nukedev"},
)
Expect(err).To(BeNil())

vault, err := config.NewLoader(configPath).GetVault(ctx, "test")
Expect(err).To(BeNil())
Expect(vault.A2ADefaultCluster).To(Equal("nukedev"))
})

It("writes the literal on-disk key", func() {
Expect(cli.Run(
ctx,
[]string{"--config", configPath, "config", "set-a2a-default-cluster", "test", "nukedev"},
)).To(BeNil())

Expect(string(readBytes(configPath))).
To(ContainSubstring("a2a_default_cluster: nukedev"))
})

It("emits the key in config list --output json", func() {
Expect(cli.Run(
ctx,
[]string{"--config", configPath, "config", "set-a2a-default-cluster", "test", "nukedev"},
)).To(BeNil())

out, err := captureStdout(func() error {
return cli.Run(ctx, []string{"--config", configPath, "config", "list", "--output", "json"})
})
Expect(err).To(BeNil())
Expect(out).To(ContainSubstring(`"a2a_default_cluster": "nukedev"`))
})

It("omits the key for a vault that names no default", func() {
out, err := captureStdout(func() error {
return cli.Run(ctx, []string{"--config", configPath, "config", "list", "--output", "json"})
})
Expect(err).To(BeNil())
// The positive guard is asserted before the absence, so an empty or
// failed run cannot satisfy this spec.
Expect(out).To(ContainSubstring(`"name": "test"`))
Expect(out).NotTo(ContainSubstring("a2a_default_cluster"))
})

It("emits a hand-edited malformed value verbatim rather than refusing it", func() {
handEdited := fmt.Sprintf(`vaults:
test:
name: test
path: %s
a2a_default_cluster: "not a cluster!"
`, vaultDir)
Expect(os.WriteFile(configPath, []byte(handEdited), 0600)).To(Succeed())

out, err := captureStdout(func() error {
return cli.Run(ctx, []string{"--config", configPath, "config", "list", "--output", "json"})
})
Expect(err).To(BeNil())
Expect(out).To(ContainSubstring(`"not a cluster!"`))
})

It("refuses a target string rather than a bare name", func() {
before := readBytes(configPath)

err := cli.Run(
ctx,
[]string{"--config", configPath, "config", "set-a2a-default-cluster", "test", "k8s:nukedev"},
)
Expect(err).NotTo(BeNil())
Expect(err.Error()).To(ContainSubstring("k8s:nukedev"))
Expect(readBytes(configPath)).To(Equal(before))
})

It("refuses a vault absent from the config and leaves the file unchanged", func() {
before := readBytes(configPath)

err := cli.Run(
ctx,
[]string{"--config", configPath, "config", "set-a2a-default-cluster", "nosuchvault", "nukedev"},
)
Expect(err).NotTo(BeNil())
Expect(readBytes(configPath)).To(Equal(before))
})

It("leaves the other vault untouched", func() {
Expect(cli.Run(
ctx,
[]string{"--config", configPath, "config", "set-a2a-default-cluster", "test", "nukedev"},
)).To(BeNil())

Expect(strings.Count(string(readBytes(configPath)), "a2a_default_cluster")).To(Equal(1))

other, err := config.NewLoader(configPath).GetVault(ctx, "other")
Expect(err).To(BeNil())
Expect(other.A2ADefaultCluster).To(BeEmpty())
Expect(other.TasksDir).To(Equal("25 Tasks"))
})

It("requires a vault name and a cluster", func() {
err := cli.Run(
ctx,
[]string{"--config", configPath, "config", "set-a2a-default-cluster", "test"},
)
Expect(err).NotTo(BeNil())
})
})
Loading
Loading