diff --git a/bot_bottle/manifest.py b/bot_bottle/manifest.py index d876fbd..19ccc6f 100644 --- a/bot_bottle/manifest.py +++ b/bot_bottle/manifest.py @@ -45,14 +45,13 @@ from __future__ import annotations import ipaddress import os -import re from dataclasses import dataclass, field, replace from pathlib import Path from typing import Mapping, cast from .agent_provider import PROVIDER_TEMPLATES from .log import warn -from .yaml_subset import YamlSubsetError, parse_frontmatter +from .manifest_schema import AGENT_MODEL_KEYS, BOTTLE_KEYS class ManifestError(Exception): @@ -629,9 +628,9 @@ class Bottle: f"removed. Move it under 'git.user'." ) - unknown = set(d.keys()) - _BOTTLE_KEYS + unknown = set(d.keys()) - BOTTLE_KEYS if unknown: - allowed = ", ".join(sorted(_BOTTLE_KEYS)) + allowed = ", ".join(sorted(BOTTLE_KEYS)) raise ManifestError( f"bottle '{name}' has unknown key(s) {sorted(unknown)}; " f"allowed keys are {allowed}." @@ -694,6 +693,13 @@ class Agent: @classmethod def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent": 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") if not isinstance(bottle, str) or not bottle: @@ -784,9 +790,11 @@ class Manifest: home_md = home_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(): - _check_stale_json(cwd_dir, cwd_md, "$CWD") + check_stale_json(cwd_dir, cwd_md, "$CWD") if not home_md.is_dir(): if missing_ok: @@ -818,11 +826,13 @@ class Manifest: Used by tests to build a Manifest from fixture directories without touching `os.environ`.""" 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()) 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: stale_bottles = cwd_dir / "bottles" @@ -837,7 +847,7 @@ class Manifest: f"(PRD 0011). Move them or delete." ) 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" ) agents = {**agents, **cwd_agents} @@ -857,7 +867,9 @@ class Manifest: raw_bottles: dict[str, dict[str, object]] = {} for n, b in raw_bottles_obj.items(): 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()) 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." ) 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 `/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 `/*.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 `/*.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 diff --git a/bot_bottle/manifest_extends.py b/bot_bottle/manifest_extends.py new file mode 100644 index 0000000..034256c --- /dev/null +++ b/bot_bottle/manifest_extends.py @@ -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()) diff --git a/bot_bottle/manifest_loader.py b/bot_bottle/manifest_loader.py new file mode 100644 index 0000000..379499b --- /dev/null +++ b/bot_bottle/manifest_loader.py @@ -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 `/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 `/*.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 `/*.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 diff --git a/bot_bottle/manifest_schema.py b/bot_bottle/manifest_schema.py new file mode 100644 index 0000000..8aebbe8 --- /dev/null +++ b/bot_bottle/manifest_schema.py @@ -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}." + ) diff --git a/tests/unit/test_manifest_md_load.py b/tests/unit/test_manifest_md_load.py index 9b6274c..4ec18dd 100644 --- a/tests/unit/test_manifest_md_load.py +++ b/tests/unit/test_manifest_md_load.py @@ -220,6 +220,80 @@ class TestAgentFileDoublesAsClaudeCodeSubagent(_ResolveCase): 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): """A typo'd / unknown frontmatter key on an agent file dies rather than silently ignoring."""