refactor(manifest): split schema boundaries
This commit is contained in:
+22
-284
@@ -45,14 +45,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Mapping, cast
|
from typing import Mapping, cast
|
||||||
|
|
||||||
from .agent_provider import PROVIDER_TEMPLATES
|
from .agent_provider import PROVIDER_TEMPLATES
|
||||||
from .log import warn
|
from .log import warn
|
||||||
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
from .manifest_schema import AGENT_MODEL_KEYS, BOTTLE_KEYS
|
||||||
|
|
||||||
|
|
||||||
class ManifestError(Exception):
|
class ManifestError(Exception):
|
||||||
@@ -629,9 +628,9 @@ class Bottle:
|
|||||||
f"removed. Move it under 'git.user'."
|
f"removed. Move it under 'git.user'."
|
||||||
)
|
)
|
||||||
|
|
||||||
unknown = set(d.keys()) - _BOTTLE_KEYS
|
unknown = set(d.keys()) - BOTTLE_KEYS
|
||||||
if unknown:
|
if unknown:
|
||||||
allowed = ", ".join(sorted(_BOTTLE_KEYS))
|
allowed = ", ".join(sorted(BOTTLE_KEYS))
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{name}' has unknown key(s) {sorted(unknown)}; "
|
f"bottle '{name}' has unknown key(s) {sorted(unknown)}; "
|
||||||
f"allowed keys are {allowed}."
|
f"allowed keys are {allowed}."
|
||||||
@@ -694,6 +693,13 @@ class Agent:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent":
|
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent":
|
||||||
d = _as_json_object(raw, f"agent '{name}'")
|
d = _as_json_object(raw, f"agent '{name}'")
|
||||||
|
unknown = set(d.keys()) - AGENT_MODEL_KEYS
|
||||||
|
if unknown:
|
||||||
|
allowed = ", ".join(sorted(AGENT_MODEL_KEYS))
|
||||||
|
raise ManifestError(
|
||||||
|
f"agent '{name}' has unknown key(s) {sorted(unknown)}; "
|
||||||
|
f"allowed keys are {allowed}."
|
||||||
|
)
|
||||||
|
|
||||||
bottle = d.get("bottle")
|
bottle = d.get("bottle")
|
||||||
if not isinstance(bottle, str) or not bottle:
|
if not isinstance(bottle, str) or not bottle:
|
||||||
@@ -784,9 +790,11 @@ class Manifest:
|
|||||||
home_md = home_dir / ".bot-bottle"
|
home_md = home_dir / ".bot-bottle"
|
||||||
cwd_md = cwd_dir / ".bot-bottle"
|
cwd_md = cwd_dir / ".bot-bottle"
|
||||||
|
|
||||||
_check_stale_json(home_dir, home_md, "$HOME")
|
from .manifest_loader import check_stale_json
|
||||||
|
|
||||||
|
check_stale_json(home_dir, home_md, "$HOME")
|
||||||
if cwd_dir.resolve() != home_dir.resolve():
|
if cwd_dir.resolve() != home_dir.resolve():
|
||||||
_check_stale_json(cwd_dir, cwd_md, "$CWD")
|
check_stale_json(cwd_dir, cwd_md, "$CWD")
|
||||||
|
|
||||||
if not home_md.is_dir():
|
if not home_md.is_dir():
|
||||||
if missing_ok:
|
if missing_ok:
|
||||||
@@ -818,11 +826,13 @@ class Manifest:
|
|||||||
Used by tests to build a Manifest from fixture directories
|
Used by tests to build a Manifest from fixture directories
|
||||||
without touching `os.environ`."""
|
without touching `os.environ`."""
|
||||||
bottles_dir = home_dir / "bottles"
|
bottles_dir = home_dir / "bottles"
|
||||||
bottles = _load_bottles_from_dir(bottles_dir)
|
from .manifest_loader import load_agents_from_dir, load_bottles_from_dir
|
||||||
|
|
||||||
|
bottles = load_bottles_from_dir(bottles_dir)
|
||||||
|
|
||||||
bottle_names = set(bottles.keys())
|
bottle_names = set(bottles.keys())
|
||||||
agents_dir = home_dir / "agents"
|
agents_dir = home_dir / "agents"
|
||||||
agents = _load_agents_from_dir(agents_dir, bottle_names, source="$HOME")
|
agents = load_agents_from_dir(agents_dir, bottle_names, source="$HOME")
|
||||||
|
|
||||||
if cwd_dir is not None:
|
if cwd_dir is not None:
|
||||||
stale_bottles = cwd_dir / "bottles"
|
stale_bottles = cwd_dir / "bottles"
|
||||||
@@ -837,7 +847,7 @@ class Manifest:
|
|||||||
f"(PRD 0011). Move them or delete."
|
f"(PRD 0011). Move them or delete."
|
||||||
)
|
)
|
||||||
cwd_agents_dir = cwd_dir / "agents"
|
cwd_agents_dir = cwd_dir / "agents"
|
||||||
cwd_agents = _load_agents_from_dir(
|
cwd_agents = load_agents_from_dir(
|
||||||
cwd_agents_dir, bottle_names, source="$CWD"
|
cwd_agents_dir, bottle_names, source="$CWD"
|
||||||
)
|
)
|
||||||
agents = {**agents, **cwd_agents}
|
agents = {**agents, **cwd_agents}
|
||||||
@@ -857,7 +867,9 @@ class Manifest:
|
|||||||
raw_bottles: dict[str, dict[str, object]] = {}
|
raw_bottles: dict[str, dict[str, object]] = {}
|
||||||
for n, b in raw_bottles_obj.items():
|
for n, b in raw_bottles_obj.items():
|
||||||
raw_bottles[n] = _as_json_object(b, f"bottle '{n}'")
|
raw_bottles[n] = _as_json_object(b, f"bottle '{n}'")
|
||||||
bottles = _resolve_bottles(raw_bottles)
|
from .manifest_extends import resolve_bottles
|
||||||
|
|
||||||
|
bottles = resolve_bottles(raw_bottles)
|
||||||
|
|
||||||
bottle_names = set(bottles.keys())
|
bottle_names = set(bottles.keys())
|
||||||
agents: dict[str, Agent] = {
|
agents: dict[str, Agent] = {
|
||||||
@@ -1055,277 +1067,3 @@ def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> N
|
|||||||
f"each entry maps to a distinct bare repo on the gate."
|
f"each entry maps to a distinct bare repo on the gate."
|
||||||
)
|
)
|
||||||
seen[g.Name] = None
|
seen[g.Name] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# --- Per-file MD loader (PRD 0011) ----------------------------------------
|
|
||||||
|
|
||||||
# Filename-as-key uses kebab-case ASCII. The first character is a
|
|
||||||
# letter so we don't conflict with hidden files / Markdown special
|
|
||||||
# names (`.md`, `_template.md`, etc.). Filenames that fail this
|
|
||||||
# pattern are skipped with a warning rather than crashing the load.
|
|
||||||
_FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
||||||
|
|
||||||
# Frontmatter keys we accept on each entity. Anything not in these
|
|
||||||
# sets dies with a "did you mean" pointer — typos shouldn't silently
|
|
||||||
# ghost into an empty config.
|
|
||||||
_BOTTLE_KEYS = frozenset(
|
|
||||||
{"env", "extends", "agent_provider", "git", "egress", "supervise"}
|
|
||||||
)
|
|
||||||
_AGENT_KEYS_REQUIRED = frozenset({"bottle"})
|
|
||||||
_AGENT_KEYS_OPTIONAL = frozenset({"skills", "git"})
|
|
||||||
# Claude Code subagent fields bot-bottle ignores at launch but
|
|
||||||
# doesn't reject — lets the same file double as `~/.claude/agents/*.md`.
|
|
||||||
_AGENT_KEYS_CC_PASSTHROUGH = frozenset({
|
|
||||||
"name", "description", "model", "color", "memory",
|
|
||||||
})
|
|
||||||
_AGENT_KEYS = (
|
|
||||||
_AGENT_KEYS_REQUIRED | _AGENT_KEYS_OPTIONAL | _AGENT_KEYS_CC_PASSTHROUGH
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
|
||||||
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
|
|
||||||
not — the manifest format changed in PRD 0011 and we don't want
|
|
||||||
to silently leave the JSON content unused."""
|
|
||||||
legacy = dir_path / "bot-bottle.json"
|
|
||||||
if legacy.is_file() and not md_dir.exists():
|
|
||||||
raise ManifestError(
|
|
||||||
f"found {legacy} but {md_dir} does not exist. The manifest "
|
|
||||||
f"format changed in PRD 0011 — rewrite the JSON content "
|
|
||||||
f"as per-file Markdown under {md_dir}/bottles/ and "
|
|
||||||
f"{md_dir}/agents/. See README.md for the schema. "
|
|
||||||
f"({label})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _entity_name_from_path(path: Path) -> str | None:
|
|
||||||
"""Return the entity name implied by the filename, or None if
|
|
||||||
the filename doesn't fit the [a-z][a-z0-9-]* convention. None
|
|
||||||
triggers a skip-with-warning at the caller."""
|
|
||||||
if path.suffix != ".md":
|
|
||||||
return None
|
|
||||||
stem = path.stem
|
|
||||||
if not _FILENAME_RX.match(stem):
|
|
||||||
return None
|
|
||||||
return stem
|
|
||||||
|
|
||||||
|
|
||||||
def _load_bottles_from_dir(bottles_dir: Path) -> dict[str, Bottle]:
|
|
||||||
"""Walk `<bottles_dir>/*.md`, parse each as a bottle, return
|
|
||||||
`{name: Bottle}`. Missing dir → empty dict (the user simply
|
|
||||||
hasn't declared any bottles yet).
|
|
||||||
|
|
||||||
Two-pass to resolve PRD 0025 `extends:` chains:
|
|
||||||
1. Collect each file's raw frontmatter into `{name: raw}`.
|
|
||||||
2. Recursively merge `extends:` chains into effective
|
|
||||||
Bottle objects (`_resolve_bottles`)."""
|
|
||||||
raws: dict[str, dict[str, object]] = {}
|
|
||||||
if not bottles_dir.is_dir():
|
|
||||||
return {}
|
|
||||||
for path in sorted(bottles_dir.glob("*.md")):
|
|
||||||
name = _entity_name_from_path(path)
|
|
||||||
if name is None:
|
|
||||||
warn(
|
|
||||||
f"skipping {path}: filename must match "
|
|
||||||
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
fm, _body = parse_frontmatter(path.read_text())
|
|
||||||
except OSError as e:
|
|
||||||
raise ManifestError(f"could not read {path}: {e}")
|
|
||||||
except YamlSubsetError as e:
|
|
||||||
raise ManifestError(f"{path}: {e}")
|
|
||||||
unknown = set(fm.keys()) - _BOTTLE_KEYS
|
|
||||||
if unknown:
|
|
||||||
allowed = ", ".join(sorted(_BOTTLE_KEYS))
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle file {path}: unknown frontmatter key(s) "
|
|
||||||
f"{sorted(unknown)}; allowed keys are {allowed}."
|
|
||||||
)
|
|
||||||
raws[name] = fm
|
|
||||||
return _resolve_bottles(raws)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, Bottle]:
|
|
||||||
"""Apply `extends:` chains (PRD 0025) and return a flat
|
|
||||||
`{name: Bottle}` of resolved configs. Cycle / missing-parent
|
|
||||||
/ self-reference die with a clear pointer."""
|
|
||||||
cache: dict[str, Bottle] = {}
|
|
||||||
for name in raws:
|
|
||||||
if name not in cache:
|
|
||||||
_resolve_one_bottle(name, raws, cache, ())
|
|
||||||
return cache
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_one_bottle(
|
|
||||||
name: str,
|
|
||||||
raws: dict[str, dict[str, object]],
|
|
||||||
cache: dict[str, Bottle],
|
|
||||||
seen: tuple[str, ...],
|
|
||||||
) -> Bottle:
|
|
||||||
"""Recursive resolver. `seen` is the current extends-chain for
|
|
||||||
cycle detection; on cycle die with the chain so the operator
|
|
||||||
can see which two files to break the loop in."""
|
|
||||||
if name in cache:
|
|
||||||
return cache[name]
|
|
||||||
if name in seen:
|
|
||||||
chain = " -> ".join(seen + (name,))
|
|
||||||
raise ManifestError(f"bottle '{name}' is in an extends cycle: {chain}")
|
|
||||||
raw = raws[name]
|
|
||||||
parent_name_raw = raw.get("extends")
|
|
||||||
# Strip `extends:` before passing to Bottle.from_dict so it
|
|
||||||
# isn't accidentally treated as a real Bottle field by future
|
|
||||||
# schema additions. It's only meaningful here.
|
|
||||||
child_raw = {k: v for k, v in raw.items() if k != "extends"}
|
|
||||||
|
|
||||||
if parent_name_raw is None:
|
|
||||||
bottle = Bottle.from_dict(name, child_raw)
|
|
||||||
cache[name] = bottle
|
|
||||||
return bottle
|
|
||||||
|
|
||||||
if not isinstance(parent_name_raw, str):
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' extends must be a string "
|
|
||||||
f"(was {type(parent_name_raw).__name__})"
|
|
||||||
)
|
|
||||||
parent_name: str = parent_name_raw
|
|
||||||
if parent_name == name:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' extends itself; remove the "
|
|
||||||
f"self-reference"
|
|
||||||
)
|
|
||||||
if parent_name not in raws:
|
|
||||||
avail = ", ".join(sorted(raws.keys())) or "(none)"
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' extends '{parent_name}' which is not "
|
|
||||||
f"defined. Available bottles: {avail}"
|
|
||||||
)
|
|
||||||
parent = _resolve_one_bottle(parent_name, raws, cache, seen + (name,))
|
|
||||||
bottle = _merge_bottles(parent, child_raw, name)
|
|
||||||
cache[name] = bottle
|
|
||||||
return bottle
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_bottles(
|
|
||||||
parent: Bottle,
|
|
||||||
child_raw: dict[str, object],
|
|
||||||
name: str,
|
|
||||||
) -> Bottle:
|
|
||||||
"""Apply PRD 0025 merge rules: parent is base; child's declared
|
|
||||||
fields overlay. env merges dict-style with child-wins on key
|
|
||||||
collision; git.user overlays per-field; git.remotes merges by
|
|
||||||
upstream host with child entries replacing duplicate hosts."""
|
|
||||||
# Parse the child's declared fields into a Bottle (with the
|
|
||||||
# usual defaults for anything missing). Validation runs the same
|
|
||||||
# way it would for a leaf bottle — typos / wrong types die here.
|
|
||||||
child = Bottle.from_dict(name, child_raw)
|
|
||||||
|
|
||||||
# env: dict merge, child wins on collision.
|
|
||||||
merged_env = {**parent.env, **child.env}
|
|
||||||
|
|
||||||
# git.user: per-field overlay. Each non-empty field on child
|
|
||||||
# wins; empties fall through to parent. The default GitUser()
|
|
||||||
# is two empty strings, so a child that omits git.user
|
|
||||||
# inherits the parent's user verbatim.
|
|
||||||
merged_git_user = GitUser(
|
|
||||||
name=child.git_user.name or parent.git_user.name,
|
|
||||||
email=child.git_user.email or parent.git_user.email,
|
|
||||||
)
|
|
||||||
|
|
||||||
# git.remotes: missing means inherit; an explicit empty object
|
|
||||||
# clears; otherwise parent and child merge by UpstreamHost with
|
|
||||||
# child entries replacing duplicate hosts.
|
|
||||||
if _child_declares_git_remotes(child_raw):
|
|
||||||
merged_git = _merge_git_remotes(parent.git, child.git) if child.git else ()
|
|
||||||
else:
|
|
||||||
merged_git = parent.git
|
|
||||||
|
|
||||||
# Presence-driven full-replace for the remaining list-valued +
|
|
||||||
# scalar fields.
|
|
||||||
merged_egress = child.egress if "egress" in child_raw else parent.egress
|
|
||||||
merged_agent_provider = (
|
|
||||||
child.agent_provider
|
|
||||||
if "agent_provider" in child_raw
|
|
||||||
else parent.agent_provider
|
|
||||||
)
|
|
||||||
merged_supervise = (
|
|
||||||
child.supervise if "supervise" in child_raw else parent.supervise
|
|
||||||
)
|
|
||||||
_validate_egress_routes(name, merged_egress.routes)
|
|
||||||
|
|
||||||
return Bottle(
|
|
||||||
env=merged_env,
|
|
||||||
agent_provider=merged_agent_provider,
|
|
||||||
git=merged_git,
|
|
||||||
git_user=merged_git_user,
|
|
||||||
egress=merged_egress,
|
|
||||||
supervise=merged_supervise,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _child_declares_git_remotes(child_raw: dict[str, object]) -> bool:
|
|
||||||
git_raw = child_raw.get("git")
|
|
||||||
if git_raw is None:
|
|
||||||
return False
|
|
||||||
git_obj = _as_json_object(git_raw, "child git")
|
|
||||||
return "remotes" in git_obj
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_git_remotes(
|
|
||||||
parent: tuple[GitEntry, ...],
|
|
||||||
child: tuple[GitEntry, ...],
|
|
||||||
) -> tuple[GitEntry, ...]:
|
|
||||||
by_host = {entry.UpstreamHost: entry for entry in parent}
|
|
||||||
for entry in child:
|
|
||||||
by_host[entry.UpstreamHost] = entry
|
|
||||||
return tuple(by_host.values())
|
|
||||||
|
|
||||||
|
|
||||||
def _load_agents_from_dir(
|
|
||||||
agents_dir: Path,
|
|
||||||
bottle_names: set[str],
|
|
||||||
*,
|
|
||||||
source: str,
|
|
||||||
) -> dict[str, Agent]:
|
|
||||||
"""Walk `<agents_dir>/*.md`, parse each as an agent, return
|
|
||||||
`{name: Agent}`. The Markdown body becomes the agent's
|
|
||||||
`prompt`. Missing dir → empty dict."""
|
|
||||||
out: dict[str, Agent] = {}
|
|
||||||
if not agents_dir.is_dir():
|
|
||||||
return out
|
|
||||||
for path in sorted(agents_dir.glob("*.md")):
|
|
||||||
name = _entity_name_from_path(path)
|
|
||||||
if name is None:
|
|
||||||
warn(
|
|
||||||
f"skipping {path}: filename must match "
|
|
||||||
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
fm, body = parse_frontmatter(path.read_text())
|
|
||||||
except OSError as e:
|
|
||||||
raise ManifestError(f"could not read {path}: {e}")
|
|
||||||
except YamlSubsetError as e:
|
|
||||||
raise ManifestError(f"{path}: {e}")
|
|
||||||
unknown = set(fm.keys()) - _AGENT_KEYS
|
|
||||||
if unknown:
|
|
||||||
allowed = ", ".join(sorted(_AGENT_KEYS))
|
|
||||||
raise ManifestError(
|
|
||||||
f"agent file {path}: unknown frontmatter key(s) "
|
|
||||||
f"{sorted(unknown)}; allowed keys are {allowed}."
|
|
||||||
)
|
|
||||||
# Build the dict Agent.from_dict expects. The body becomes
|
|
||||||
# prompt; CC passthrough fields stay in fm and get ignored
|
|
||||||
# by from_dict (which reads bottle/skills/git/prompt).
|
|
||||||
agent_dict: dict[str, object] = {
|
|
||||||
"bottle": fm.get("bottle"),
|
|
||||||
"skills": fm.get("skills", []),
|
|
||||||
"prompt": body.strip(),
|
|
||||||
}
|
|
||||||
if "git" in fm:
|
|
||||||
agent_dict["git"] = fm["git"]
|
|
||||||
out[name] = Agent.from_dict(name, agent_dict, bottle_names)
|
|
||||||
return out
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Internal bottle `extends:` resolution for manifests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .manifest import Bottle, GitEntry
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, Bottle]:
|
||||||
|
"""Apply `extends:` chains and return resolved Bottle objects."""
|
||||||
|
cache: dict[str, Bottle] = {}
|
||||||
|
for name in raws:
|
||||||
|
if name not in cache:
|
||||||
|
_resolve_one_bottle(name, raws, cache, ())
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_one_bottle(
|
||||||
|
name: str,
|
||||||
|
raws: dict[str, dict[str, object]],
|
||||||
|
cache: dict[str, Bottle],
|
||||||
|
seen: tuple[str, ...],
|
||||||
|
) -> Bottle:
|
||||||
|
from .manifest import Bottle, ManifestError
|
||||||
|
|
||||||
|
if name in cache:
|
||||||
|
return cache[name]
|
||||||
|
if name in seen:
|
||||||
|
chain = " -> ".join(seen + (name,))
|
||||||
|
raise ManifestError(f"bottle '{name}' is in an extends cycle: {chain}")
|
||||||
|
raw = raws[name]
|
||||||
|
parent_name_raw = raw.get("extends")
|
||||||
|
# Strip `extends:` before passing to Bottle.from_dict so it
|
||||||
|
# is not accidentally treated as a real Bottle field by future
|
||||||
|
# schema additions. It is only meaningful here.
|
||||||
|
child_raw = {k: v for k, v in raw.items() if k != "extends"}
|
||||||
|
|
||||||
|
if parent_name_raw is None:
|
||||||
|
bottle = Bottle.from_dict(name, child_raw)
|
||||||
|
cache[name] = bottle
|
||||||
|
return bottle
|
||||||
|
|
||||||
|
if not isinstance(parent_name_raw, str):
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' extends must be a string "
|
||||||
|
f"(was {type(parent_name_raw).__name__})"
|
||||||
|
)
|
||||||
|
parent_name: str = parent_name_raw
|
||||||
|
if parent_name == name:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' extends itself; remove the "
|
||||||
|
f"self-reference"
|
||||||
|
)
|
||||||
|
if parent_name not in raws:
|
||||||
|
avail = ", ".join(sorted(raws.keys())) or "(none)"
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' extends '{parent_name}' which is not "
|
||||||
|
f"defined. Available bottles: {avail}"
|
||||||
|
)
|
||||||
|
parent = _resolve_one_bottle(parent_name, raws, cache, seen + (name,))
|
||||||
|
bottle = _merge_bottles(parent, child_raw, name)
|
||||||
|
cache[name] = bottle
|
||||||
|
return bottle
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_bottles(
|
||||||
|
parent: Bottle,
|
||||||
|
child_raw: dict[str, object],
|
||||||
|
name: str,
|
||||||
|
) -> Bottle:
|
||||||
|
"""Apply PRD 0025 merge rules."""
|
||||||
|
from .manifest import Bottle, GitUser, _validate_egress_routes
|
||||||
|
|
||||||
|
# Parse the child's declared fields into a Bottle (with the
|
||||||
|
# usual defaults for anything missing). Validation runs the same
|
||||||
|
# way it would for a leaf bottle: typos / wrong types die here.
|
||||||
|
child = Bottle.from_dict(name, child_raw)
|
||||||
|
|
||||||
|
# env: dict merge, child wins on collision.
|
||||||
|
merged_env = {**parent.env, **child.env}
|
||||||
|
|
||||||
|
# git.user: per-field overlay. Each non-empty field on child
|
||||||
|
# wins; empties fall through to parent. The default GitUser()
|
||||||
|
# is two empty strings, so a child that omits git.user
|
||||||
|
# inherits the parent's user verbatim.
|
||||||
|
merged_git_user = GitUser(
|
||||||
|
name=child.git_user.name or parent.git_user.name,
|
||||||
|
email=child.git_user.email or parent.git_user.email,
|
||||||
|
)
|
||||||
|
|
||||||
|
# git.remotes: missing means inherit; an explicit empty object
|
||||||
|
# clears; otherwise parent and child merge by UpstreamHost with
|
||||||
|
# child entries replacing duplicate hosts.
|
||||||
|
if _child_declares_git_remotes(child_raw):
|
||||||
|
merged_git = _merge_git_remotes(parent.git, child.git) if child.git else ()
|
||||||
|
else:
|
||||||
|
merged_git = parent.git
|
||||||
|
|
||||||
|
# Presence-driven full-replace for the remaining list-valued +
|
||||||
|
# scalar fields.
|
||||||
|
merged_egress = child.egress if "egress" in child_raw else parent.egress
|
||||||
|
merged_agent_provider = (
|
||||||
|
child.agent_provider
|
||||||
|
if "agent_provider" in child_raw
|
||||||
|
else parent.agent_provider
|
||||||
|
)
|
||||||
|
merged_supervise = (
|
||||||
|
child.supervise if "supervise" in child_raw else parent.supervise
|
||||||
|
)
|
||||||
|
_validate_egress_routes(name, merged_egress.routes)
|
||||||
|
|
||||||
|
return Bottle(
|
||||||
|
env=merged_env,
|
||||||
|
agent_provider=merged_agent_provider,
|
||||||
|
git=merged_git,
|
||||||
|
git_user=merged_git_user,
|
||||||
|
egress=merged_egress,
|
||||||
|
supervise=merged_supervise,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _child_declares_git_remotes(child_raw: dict[str, object]) -> bool:
|
||||||
|
from .manifest import _as_json_object
|
||||||
|
|
||||||
|
git_raw = child_raw.get("git")
|
||||||
|
if git_raw is None:
|
||||||
|
return False
|
||||||
|
git_obj = _as_json_object(git_raw, "child git")
|
||||||
|
return "remotes" in git_obj
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_git_remotes(
|
||||||
|
parent: tuple[GitEntry, ...],
|
||||||
|
child: tuple[GitEntry, ...],
|
||||||
|
) -> tuple[GitEntry, ...]:
|
||||||
|
by_host = {entry.UpstreamHost: entry for entry in parent}
|
||||||
|
for entry in child:
|
||||||
|
by_host[entry.UpstreamHost] = entry
|
||||||
|
return tuple(by_host.values())
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Internal per-file Markdown manifest loader."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from .log import warn
|
||||||
|
from .manifest_schema import (
|
||||||
|
entity_name_from_path,
|
||||||
|
validate_agent_frontmatter_keys,
|
||||||
|
validate_bottle_frontmatter_keys,
|
||||||
|
)
|
||||||
|
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .manifest import Agent, Bottle
|
||||||
|
|
||||||
|
|
||||||
|
def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
||||||
|
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
|
||||||
|
not. The manifest format changed in PRD 0011 and we do not want
|
||||||
|
to silently leave the JSON content unused."""
|
||||||
|
from .manifest import ManifestError
|
||||||
|
|
||||||
|
legacy = dir_path / "bot-bottle.json"
|
||||||
|
if legacy.is_file() and not md_dir.exists():
|
||||||
|
raise ManifestError(
|
||||||
|
f"found {legacy} but {md_dir} does not exist. The manifest "
|
||||||
|
f"format changed in PRD 0011 — rewrite the JSON content "
|
||||||
|
f"as per-file Markdown under {md_dir}/bottles/ and "
|
||||||
|
f"{md_dir}/agents/. See README.md for the schema. "
|
||||||
|
f"({label})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_bottles_from_dir(bottles_dir: Path) -> dict[str, Bottle]:
|
||||||
|
"""Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
|
||||||
|
`{name: Bottle}`. Missing dir returns an empty dict."""
|
||||||
|
from .manifest import ManifestError
|
||||||
|
from .manifest_extends import resolve_bottles
|
||||||
|
|
||||||
|
raws: dict[str, dict[str, object]] = {}
|
||||||
|
if not bottles_dir.is_dir():
|
||||||
|
return {}
|
||||||
|
for path in sorted(bottles_dir.glob("*.md")):
|
||||||
|
name = entity_name_from_path(path)
|
||||||
|
if name is None:
|
||||||
|
warn(
|
||||||
|
f"skipping {path}: filename must match "
|
||||||
|
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fm, _body = parse_frontmatter(path.read_text())
|
||||||
|
except OSError as e:
|
||||||
|
raise ManifestError(f"could not read {path}: {e}")
|
||||||
|
except YamlSubsetError as e:
|
||||||
|
raise ManifestError(f"{path}: {e}")
|
||||||
|
validate_bottle_frontmatter_keys(path, fm.keys())
|
||||||
|
raws[name] = fm
|
||||||
|
return resolve_bottles(raws)
|
||||||
|
|
||||||
|
|
||||||
|
def load_agents_from_dir(
|
||||||
|
agents_dir: Path,
|
||||||
|
bottle_names: set[str],
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
) -> dict[str, Agent]:
|
||||||
|
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
|
||||||
|
`{name: Agent}`. The Markdown body becomes the agent's prompt.
|
||||||
|
Missing dir returns an empty dict."""
|
||||||
|
from .manifest import Agent, ManifestError
|
||||||
|
|
||||||
|
out: dict[str, Agent] = {}
|
||||||
|
if not agents_dir.is_dir():
|
||||||
|
return out
|
||||||
|
for path in sorted(agents_dir.glob("*.md")):
|
||||||
|
name = entity_name_from_path(path)
|
||||||
|
if name is None:
|
||||||
|
warn(
|
||||||
|
f"skipping {path}: filename must match "
|
||||||
|
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fm, body = parse_frontmatter(path.read_text())
|
||||||
|
except OSError as e:
|
||||||
|
raise ManifestError(f"could not read {path}: {e}")
|
||||||
|
except YamlSubsetError as e:
|
||||||
|
raise ManifestError(f"{path}: {e}")
|
||||||
|
validate_agent_frontmatter_keys(path, fm.keys())
|
||||||
|
# Build the dict Agent.from_dict expects. The body becomes
|
||||||
|
# prompt; Claude Code passthrough fields stay in fm and get
|
||||||
|
# ignored by Agent.from_dict (which reads bottle/skills/git/prompt).
|
||||||
|
agent_dict: dict[str, object] = {
|
||||||
|
"bottle": fm.get("bottle"),
|
||||||
|
"skills": fm.get("skills", []),
|
||||||
|
"prompt": body.strip(),
|
||||||
|
}
|
||||||
|
if "git" in fm:
|
||||||
|
agent_dict["git"] = fm["git"]
|
||||||
|
out[name] = Agent.from_dict(name, agent_dict, bottle_names)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Internal manifest schema policy helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Filename-as-key uses kebab-case ASCII. The first character is a
|
||||||
|
# letter so we don't conflict with hidden files / Markdown special
|
||||||
|
# names (`.md`, `_template.md`, etc.). Filenames that fail this
|
||||||
|
# pattern are skipped with a warning rather than crashing the load.
|
||||||
|
_FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||||
|
|
||||||
|
|
||||||
|
# Frontmatter keys we accept on each entity. Anything not in these
|
||||||
|
# sets dies with a "did you mean" pointer: typos should not silently
|
||||||
|
# ghost into an empty config.
|
||||||
|
BOTTLE_KEYS = frozenset(
|
||||||
|
{"env", "extends", "agent_provider", "git", "egress", "supervise"}
|
||||||
|
)
|
||||||
|
AGENT_KEYS_REQUIRED = frozenset({"bottle"})
|
||||||
|
AGENT_KEYS_OPTIONAL = frozenset({"skills", "git"})
|
||||||
|
|
||||||
|
# Claude Code subagent fields bot-bottle ignores at launch but does
|
||||||
|
# not reject. This lets the same file double as
|
||||||
|
# `~/.claude/agents/*.md` without modification.
|
||||||
|
CLAUDE_CODE_AGENT_PASSTHROUGH_KEYS = frozenset({
|
||||||
|
"name", "description", "model", "color", "memory",
|
||||||
|
})
|
||||||
|
AGENT_KEYS = (
|
||||||
|
AGENT_KEYS_REQUIRED | AGENT_KEYS_OPTIONAL | CLAUDE_CODE_AGENT_PASSTHROUGH_KEYS
|
||||||
|
)
|
||||||
|
AGENT_MODEL_KEYS = AGENT_KEYS | frozenset({"prompt"})
|
||||||
|
|
||||||
|
|
||||||
|
def entity_name_from_path(path: Path) -> str | None:
|
||||||
|
"""Return the entity name implied by the filename, or None if the
|
||||||
|
filename does not fit the [a-z][a-z0-9-]* convention."""
|
||||||
|
if path.suffix != ".md":
|
||||||
|
return None
|
||||||
|
stem = path.stem
|
||||||
|
if not _FILENAME_RX.match(stem):
|
||||||
|
return None
|
||||||
|
return stem
|
||||||
|
|
||||||
|
|
||||||
|
def validate_bottle_frontmatter_keys(path: Path, keys: object) -> None:
|
||||||
|
_validate_frontmatter_keys("bottle", path, keys, BOTTLE_KEYS)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_agent_frontmatter_keys(path: Path, keys: object) -> None:
|
||||||
|
_validate_frontmatter_keys("agent", path, keys, AGENT_KEYS)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_frontmatter_keys(
|
||||||
|
kind: str,
|
||||||
|
path: Path,
|
||||||
|
keys: object,
|
||||||
|
allowed_keys: frozenset[str],
|
||||||
|
) -> None:
|
||||||
|
from .manifest import ManifestError
|
||||||
|
|
||||||
|
key_set = set(keys)
|
||||||
|
unknown = key_set - allowed_keys
|
||||||
|
if unknown:
|
||||||
|
allowed = ", ".join(sorted(allowed_keys))
|
||||||
|
raise ManifestError(
|
||||||
|
f"{kind} file {path}: unknown frontmatter key(s) "
|
||||||
|
f"{sorted(unknown)}; allowed keys are {allowed}."
|
||||||
|
)
|
||||||
@@ -220,6 +220,80 @@ class TestAgentFileDoublesAsClaudeCodeSubagent(_ResolveCase):
|
|||||||
self.assertEqual(("init-prd",), m.agents["implementer"].skills)
|
self.assertEqual(("init-prd",), m.agents["implementer"].skills)
|
||||||
|
|
||||||
|
|
||||||
|
class TestManifestEntryPointParity(_ResolveCase):
|
||||||
|
"""The MD and JSON entry points share validation and composition
|
||||||
|
behavior for the same raw manifest shape."""
|
||||||
|
|
||||||
|
def test_agent_prompt_and_skills_match_json_entry(self):
|
||||||
|
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||||
|
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
||||||
|
|
||||||
|
md_manifest = self.resolve()
|
||||||
|
json_manifest = Manifest.from_json_obj({
|
||||||
|
"bottles": {
|
||||||
|
"dev": {
|
||||||
|
"egress": {
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"host": "api.anthropic.com",
|
||||||
|
"auth": {
|
||||||
|
"scheme": "Bearer",
|
||||||
|
"token_ref": "CLAUDE_CODE_OAUTH_TOKEN",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"host": "example.com"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"implementer": {
|
||||||
|
"bottle": "dev",
|
||||||
|
"skills": ["init-prd"],
|
||||||
|
"prompt": "You are a feature implementation agent.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
md_manifest.agents["implementer"],
|
||||||
|
json_manifest.agents["implementer"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
md_manifest.bottles["dev"].egress.routes,
|
||||||
|
json_manifest.bottles["dev"].egress.routes,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_json_agent_rejects_unknown_keys(self):
|
||||||
|
with self.assertRaises(ManifestError):
|
||||||
|
Manifest.from_json_obj({
|
||||||
|
"bottles": {"dev": {}},
|
||||||
|
"agents": {
|
||||||
|
"implementer": {
|
||||||
|
"bottle": "dev",
|
||||||
|
"skillz": ["init-prd"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_json_agent_accepts_claude_code_passthrough_keys(self):
|
||||||
|
manifest = Manifest.from_json_obj({
|
||||||
|
"bottles": {"dev": {}},
|
||||||
|
"agents": {
|
||||||
|
"implementer": {
|
||||||
|
"name": "implementer",
|
||||||
|
"description": "Implements features against PRDs.",
|
||||||
|
"model": "opus",
|
||||||
|
"color": "blue",
|
||||||
|
"memory": "project",
|
||||||
|
"bottle": "dev",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual("dev", manifest.agents["implementer"].bottle)
|
||||||
|
|
||||||
|
|
||||||
class TestUnknownAgentKeyDies(_ResolveCase):
|
class TestUnknownAgentKeyDies(_ResolveCase):
|
||||||
"""A typo'd / unknown frontmatter key on an agent file dies
|
"""A typo'd / unknown frontmatter key on an agent file dies
|
||||||
rather than silently ignoring."""
|
rather than silently ignoring."""
|
||||||
|
|||||||
Reference in New Issue
Block a user