Open adapter, umbrella, and merge-backend registries
Context
dotpack's product intent is universal coverage of LLM coding tools across every operation. The implementation, however, treated the current host matrix as the architectural boundary:
- Per-host adapters were hard-coded in
internal/cli/install.govia theadapterFactoriesmap, and umbrellas viaumbrellaFactories. Adding a host meant editing CLI core.internal/cli/sync.go(scanMaterializedFiles) andinternal/cli/root.gore-hard-coded the same host→layout table. - The only umbrella,
agents-cli, declaredcommandandmemoryunsupported even though every sub-adapter already supported both. - Config-fragment merging dispatched by a closed
mergedFormatenum + switch wired only for.jsonand.toml. - README presented a closed "Supported Targets" matrix; CONTRIBUTING had no documented path to add an adapter.
This ADR makes those extension points first-class so onboarding a tool or a
config format is mechanical and local, without editing scattered core
switchboards. It supersedes the adapterFactories/umbrellaFactories location
described in ADR-0012 §10;
the umbrella semantics in ADR-0012 are unchanged.
Decision
1. Self-registration adapter + umbrella registry
A new package internal/adapter/registry holds the adapter factory map and the
umbrella declarations as data. It imports only {adapter, dirs, resource} — a
strict superset of what internal/adapter imports — so any adapter sub-package
can import it without a cycle, and it must never import the concrete adapter
packages.
- Each adapter sub-package registers itself from an
init():registry.RegisterAdapter(hostID, func(d) adapter.Adapter { return New(d) }). The factory closure is mandatory (Go has no return-type variance). internal/adapter/allblank-imports the concrete adapter packages (and the umbrella declaration) for theirinit()side effects. Commands importallonce instead of naming each host.internal/cliresolves hosts/umbrellas only through the registry (Build,IsAdapter,IsUmbrella,BuildUmbrella,AdapterHostIDs), and callsregistry.Validate()from its owninit()— which runs after all registrations because Go runs imported packages'init()s first.Validatereproduces the prior fail-fast cross-reference checks (every sub/writer is a registered adapter; every writer is insubs).
Onboarding a host is now: create internal/adapter/<host>/, implement Adapter
(+ optional LayoutDescriber), self-register in init(), add one blank import
to internal/adapter/all, and add a <Host>Home field to dirs.Dirs. No core
switchboard edit. This is proven by a test that registers a fake host and
installs through the unchanged orchestrator.
2. agents-cli covers all seven operations
The agents-cli umbrella self-registers in internal/adapter/all and now lists
command and memory as fan-out writers across all sub-adapters. The writes are
distinct, non-overlapping files per host (.gemini/commands/x.toml vs
.antigravity/commands/x.md vs .codex/commands/x.md; GEMINI.md vs
ANTIGRAVITY.md vs AGENTS.md), so the existing aggregate-plan / preflight
machinery handles them unchanged. Skill remains write-once to the shared
~/.agents/skills/ convergence path.
3. Pluggable config-merge backends
internal/orchestrator/mergedkeys.go no longer dispatches on a mergedFormat
enum. A mergeBackend interface (apply, unmerge, readRootForPreflight,
parsePath) is keyed by file extension in a registry (mergebackends.go).
.json and .toml wrap the existing functions byte-for-byte; a real .yaml /
.yml backend (gopkg.in/yaml.v3) ships to prove the set is open. The
format-agnostic map-walk primitives and the json.Marshal-based selectorFor
content hash stay shared across all backends, so append-uninstall identity is
format-independent. A new format is one mergeBackend + one registerBackend.
4. Per-operation support is per-adapter data
Support for an operation is expressed by an adapter's policy (filedrop Layouts
membership / configfrag Kinds membership), surfacing the standard
kind X not yet supported error when absent. The new OpenCode adapter
(internal/adapter/opencode) ships a deliberately partial matrix — skill,
agent, command, memory, mcp-server (JSON into opencode.json $.mcp), with rule
and hook unsupported — and needed no special-casing anywhere in core.
5. Registry-driven scan and help
scanMaterializedFiles and root.go's help iterate registry.AdapterHostIDs()
and an optional adapter.LayoutDescriber (DescribeLayouts() []KindLayout)
instead of a hard-coded host→layout table, so a new host is scanned and listed
automatically.
Consequences
- Adding a host or a config format is mechanical and local; core flow names no concrete host. Proven by openness tests (fake adapter, fake merge backend).
- Existing behavior for claude-code, gemini-cli, antigravity-cli, codex, opencode, hermes, and agents-cli is preserved (the JSON/TOML/YAML backends wrap the prior functions; the registry reproduces the prior validation).
Gap register (irreducible / backlog)
- codex
.agents/skillswrite-once convergence stays a documented umbrella special case (the shared read path for codex + gemini). - Per-host hook event-name remaps (
PreToolUse/PostToolUse↔BeforeTool/AfterTool) live in each adapter's emit functions. - OpenCode extension fidelity: opencode is not yet in
schema/*.yamlhost aliases, so resource extensions are dropped (lossy) on opencode installs; universal-core fields install fine. Adding the aliases is the follow-up. - Pi adapter is named backlog; the onboarding path is the one in §1, only per-host paths need confirming.
- YAML non-string keys: the YAML backend preserves a non-string-keyed map
(
1: x) sibling byte-stable, but a (pathological) dotpack-merged path that descends into such a node surfaces a walker error rather than being coerced — coercion would rewrite bytes dotpack does not own. No shipped adapter targets YAML, so this is latent.