feat(bottle): composition via extends: (PRD 0025, issue #88)
#89
Reference in New Issue
Block a user
Delete Branch "bottle-extends-prd-0025"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #88.
Summary
Bottles can declare
extends: <other-bottle>to inherit and override config. Picked from the #88 design discussion — option 3, the alternative to allowing agents to inline abottle_config:block. Preserves the README's home-only-bottle trust boundary while addressing the duplication pain.Schema
Merge rules
envgitgit:git_useregressegress:supervisesupervise:List-valued fields full-replace because partial merge is ambiguous (ordering + name-collision semantics make it under-specified).
envis dict-merge because dict-keyed override is the natural shape.git_useroverlays per-field so a parent can declare justnameand a child can addemail. An explicitgit: []clears the parent's list; an omittedgit:inherits it.See PRD 0025 for the full design + rationale + alternatives considered.
Changes (3 commits)
docs(prd): 0025— design doc.feat(manifest)—extends:schema + recursive resolver (_resolve_bottles→_resolve_one_bottle→_merge_bottles) + cycle / missing-parent / self-extends detection. Wired into both_load_bottles_from_dir(md loader) andManifest.from_json_obj(JSON path) so downstream code (provisioners, compose renderer, etc.) consumes already-merged bottles. 24 unit tests intest_manifest_extends.py.docs(readme)—extends:section in the manifest docs (rules table + example + trust-boundary note).Tests
685 unit tests pass (24 new). Coverage:
extends:) parses unchangedenvdict merge: disjoint, collision (child wins), child-omitsgit_useroverlay: parent-only, child-overrides-both, partial (name from parent + email from child, and vice versa)extends:Trust boundary
Bottles continue to load from
$HOME/.claude-bottle/bottles/only.extends:references another file in that same directory; no cwd-readable file gains the ability to declare bottle config. The README's manifest section now documents the new field directly under the trust-boundary callout.extends:(issue #88)extends:\resolver (PRD 0025, #88)Add an optional `extends: <bottle-name>` field to bottle frontmatter. Two-pass load: 1. Collect raw frontmatter for every bottle file. 2. Recursively resolve each name into a merged Bottle via `_resolve_one_bottle` + `_merge_bottles`. Merge rules (per PRD 0025): - env: dict merge, child wins on key collision - git: full replace if child declares `git:` - git_user: per-field overlay (child's non-empty fields win) - egress: full replace if child declares `egress:` - supervise: full replace if child declares `supervise:` List-valued fields full-replace because partial merge is ambiguous (ordering matters, name collisions ambiguous); env is dict-merge because dict-keyed override is the natural shape. git_user overlays per-field so a parent can declare just the name and a child can add just the email. Cycles / self-extends / missing-parent / non-string `extends:` all die at parse with a pointer that includes the chain (cycles) or the available names (missing parent). Resolution is cached per-name so a diamond reference graph doesn't reparse the same parent N times. Both load paths threaded: - `_load_bottles_from_dir` (md files) — collect raws, then resolve. - `Manifest.from_json_obj` (JSON / test fixtures) — same. Tests (24, in `test_manifest_extends.py`): - Leaf without extends parses unchanged - Child inherits parent unchanged when child only declares `extends:` - env: disjoint union, collision (child wins), child-omits - git: replace, omit, explicit-empty-clears-parent - egress: same shape (replace, inherit) - git_user: parent-only, child-overrides-both, partial fields - 3-step chain (grandparent → parent → child) - Errors: missing parent, self-extends, 2-node cycle, 3-node cycle, non-string extends 685 unit tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>extends:composition (PRD 0025)@@ -251,0 +256,4 @@child's declared fields overlay. Merge rules:- `env:` — dict merge, child wins on key collision.- `git:`, `egress:`, `supervise:` — full replace when the childwe should probably refactor how the git config is structured: there should be two subkeys, “user” and “remotes”.
Weigh in on the design/wait for feedback before implementing
I agree with this direction. The current split between
git:as a full-replace list andgit_user:as a separate per-field overlay is starting to encode two different merge models for one conceptual feature, and the list shape makes targeted remote overrides awkward.My preferred shape would be:
Merge semantics:
git.useroverlays per field, child wins on duplicate non-empty fields. This preserves the usefulgit_userbehavior.git.remotesmerges by hostname key, child wins for a duplicate host.Upstream,IdentityFile,KnownHostKey,ExtraHosts), so partial deep merge could accidentally combine credentials/host keys from different intended remotes.git.remotes: {}should probably clear inherited remotes, matching the existinggit: []escape hatch.I would also keep a compatibility layer for the current
git:list +git_user:for at least one transition window: parse both, normalize internally to the new shape, document the new schema, and warn when the old keys are used. If both old and new forms are present in one bottle, I think that should be a validation error rather than trying to reconcile them.Pausing here as requested before implementation.
Don't worry about compatibility/not yet released.
Got it. I will drop the compatibility layer from the plan since this has not shipped yet.
Updated implementation direction:
git:list /git_user:shape with the newgit.userandgit.remotesshape directly.git_user:an unknown/invalid key under the normal validation path.git.remotesas a hostname-keyed dict and merge by host acrossextends, with child entries replacing the full remote entry for duplicate hosts.git.useras a per-field overlay where child fields win.Pausing again before implementation.
Go for it
Implemented and pushed in
59ee32c.What changed:
git:list /git_user:shape withgit.userand host-keyedgit.remotes.git.userkeeps the per-field overlay semantics acrossextends.git.remotesnow merges by upstream host; child entries replace the full remote entry for duplicate hosts.git.remotes: {}explicitly clears inherited remotes.git_usernow fails validation with a pointer togit.user.Verification:
python3 -m unittest tests.unit.test_smolmachines_provision tests.unit.test_manifest_extends tests.unit.test_manifest_gitpython3 -m unittest discover tests/unitpassed outside the sandbox; the sandboxed run only failed on local127.0.0.1bind permissions for supervise HTTP tests.Pausing here for review feedback.