TL;DR: Go’s embed ships files inside your binary, but each embed.FS is an island, declared in one package. A modular CLI has many of them, and its features need to contribute to shared resources like the default config.yaml without editing a central file. props.Assets merges every registered embed.FS into one fs.FS. Open a structured file through it and it finds every copy across all the islands, deep-merges them, and hands you the combined result.

embed.FS is an island

Go’s embed package is genuinely lovely. A //go:embed directive and your default config, your templates, your docs are baked into the binary. The tool works the moment it’s installed, with no external files to lose.

But an embed.FS has a property that’s easy to miss until it bites: it is local to the package that declared it. The //go:embed directive can only see files at or below its own source file. So in any project bigger than a toy, you don’t have an embedded filesystem. You have many. The root package embeds one. Each feature, each subcommand that ships its own templates or defaults, embeds another. They’re islands, one per package, and Go gives you no native way to make them behave as a whole.

For most files that’s fine. A feature’s templates can stay on the feature’s island; nothing else needs them.

It stops being fine the moment features need to contribute to something shared.

The shared-config problem

Here’s the case that forces the issue. A go-tool-base tool has a global config.yaml of defaults, embedded at the root. Now you add a feature, and that feature has its own configuration keys, with their own sensible defaults.

Where do those defaults go?

The naive answer is: edit the root config.yaml and add the feature’s section. And that’s a genuinely bad answer, because it inverts the dependency. The root config now has to know about every feature. Add a feature, edit the centre. Remove one, edit the centre again. The central file becomes a pinch point that every feature has to reach into, and a modular architecture where the modules can’t be added without editing the core isn’t really modular.

What you actually want is for the feature to ship its own slice of default config, on its own island, and for the global config the tool reads to somehow already contain it. The feature contributes; the centre doesn’t change.

props.Assets: merge the islands

That’s the job of props.Assets. It’s a layer that implements the standard fs.FS interface, and into it you Register each embed.FS under a name:

// root main.go
Assets: props.NewAssets(props.AssetMap{"root": &assets}),
// a feature's command constructor
//go:embed assets/*
var assets embed.FS

func NewCmdFeature(p *props.Props) *cobra.Command {
    p.Assets.Register("feature", &assets)
    // ...
}

Now Props carries one Assets value that represents all the islands as a single filesystem. The root’s files and every registered feature’s files, addressable through one fs.FS. Each registration is named, so the islands stay individually identifiable, but they read as one.

That alone solves the addressing problem. The genuinely clever part is what happens for structured files.

Opening a file that exists in several places

When you Open a path through props.Assets and that path has a structured extension — .yaml, .yml, .json, .csv — it does not simply return the first match. It does this:

  1. Discovery. It finds every instance of that path, across every registered filesystem.
  2. Parsing. It unmarshals each one.
  3. Merging. It deep-merges the parsed data, using mergo.
  4. Re-serialisation. It hands you back a single fs.File whose contents are the combined, merged result.

So picture the shared-config problem again, solved. The root ships a config.yaml with the base defaults. Each feature ships a config.yaml on its own island carrying only its own keys. Nobody edits anybody else’s file. When the init command opens config.yaml through props.Assets, it doesn’t get the root’s copy. It gets the deep-merge of the root’s copy and every registered feature’s copy: one config.yaml that contains every default in the tool, assembled at runtime from contributions that never knew about each other.

A feature contributes its defaults by existing and registering. The centre never changes. That’s the modular property the naive approach couldn’t give you, and it generalises beyond config — the same merge applies to a shared commands.csv, or any structured file features want to add rows or keys to.

There’s also a Mount method for attaching an arbitrary fs.FS at a virtual path, which is handy for surfacing something external (a temp directory, say) as part of the same tree. But the structured merge is the feature that earns Assets its place.

Where this leaves us

embed.FS is per-package by design, so a modular CLI accumulates many embedded filesystems, one island per feature. Most of the time that’s fine. It fails specifically when features need to contribute to a shared resource, like the global config.yaml, because the naive fix forces every feature to edit a central file.

props.Assets merges all the registered islands into a single fs.FS, and for structured files it goes further: opening a .yaml, .json or .csv discovers every copy across every island, deep-merges them, and returns the combined whole. A feature drops its own defaults onto its own island, registers, and the merged config the tool reads already includes them. Contribution without coupling, which is the whole point of being modular.