"""The `Manifest` / `ManifestIndex` aggregate model. The parsed manifest document (`Manifest`) and the multi-agent index over it (`ManifestIndex`), plus the bottle-resolution helpers that stitch the pieces (`agent`, `bottle`, `egress`, `git`, `extends`, `loader`, `schema`) into an effective manifest. This is the heavy aggregate — the thin package `__init__` re-exports `Manifest` / `ManifestIndex` lazily. """ from __future__ import annotations import os from dataclasses import dataclass, field, replace from pathlib import Path from typing import Mapping from ..log import warn from .util import ManifestError, as_json_object from .agent import ManifestAgent from .bottle import ManifestBottle from .extends import merge_bottles_runtime, resolve_bottles from .forge import ResolvedForgeAssociation, resolve_forge_associations from .git import ManifestGitUser from .loader import ( check_stale_json, load_bottle_chain_from_dir, scan_agent_names, scan_bottle_names, ) from .schema import validate_agent_frontmatter_keys from ..yaml_subset import YamlSubsetError, parse_frontmatter def _section_dict(value: object, label: str) -> dict[str, object]: """Like as_json_object but treats absent/null as an empty section.""" if value is None: return {} return as_json_object(value, label) def _warn_ignored_cwd_dir(cwd_dir: Path, kind: str, home_path: str) -> None: """Warn (once) that manifest files of `kind` under `$CWD/.bot-bottle/` are ignored — the filesystem layout IS the trust boundary. `kind` is the subdir name (`bottles`/`agents`); `home_path` is where they belong.""" stale = cwd_dir / kind if not stale.is_dir(): return files = sorted(stale.glob("*.md")) if not files: return names = ", ".join(p.name for p in files) warn( f"ignoring {kind[:-1]} file(s) under {stale}: {names}. " f"{kind.capitalize()} can only live under {home_path} " f"(PRD prd-new-trusted-agent-forge-identity). Move them or delete." ) def _compose_manifest( agent_name: str, agent: "ManifestAgent", raw_bottle: "ManifestBottle", ) -> "Manifest": """Build the single-value Manifest from the selected agent and its effective bottle (PRD prd-new-trusted-agent-forge-identity): - the agent's `author` populates the bottle's git user.name/user.email; - each git-gate repo's `forge` alias is resolved against the agent's `forge-accounts` (failing closed on an unknown alias) into the Manifest's forge associations. Shared by the eager (from_json_obj) and lazy (from_md_dirs) paths.""" identity = ( ManifestGitUser(name=agent.author.name, email=agent.author.email) if agent.author is not None else ManifestGitUser() ) bottle = ( raw_bottle if identity == raw_bottle.git_user else replace(raw_bottle, git_user=identity) ) associations = resolve_forge_associations( agent_name, dict(agent.forge_accounts), bottle.git, ) return Manifest(agent=agent, bottle=bottle, forge_associations=associations) def _resolve_effective_bottle_eager( agent_name: str, agent: "ManifestAgent", bottle_names: "tuple[str, ...]", bottles: "Mapping[str, ManifestBottle]", ) -> "ManifestBottle": """Return the effective ManifestBottle for the eager (from_json_obj) path. When bottle_names is non-empty they are merged in order. When empty, falls back to agent.bottle. Raises ManifestError when neither is set.""" if bottle_names: resolved: list[ManifestBottle] = [] for bn in bottle_names: if bn not in bottles: available = ", ".join(sorted(bottles.keys())) or "(none)" raise ManifestError( f"bottle '{bn}' not defined. Available: {available}" ) resolved.append(bottles[bn]) return merge_bottles_runtime(resolved) if not agent.bottle: raise ManifestError( f"agent '{agent_name}' has no 'bottle' field and no bottles were " f"selected at launch. Select at least one bottle or add " f"'bottle: ' to the agent manifest." ) return bottles[agent.bottle] def _resolve_effective_bottle_lazy( agent_name: str, agent_bottle: str, bottle_names: "tuple[str, ...]", bottles_dir: "Path", ) -> "ManifestBottle": """Return the effective ManifestBottle for the lazy (from_md_dirs) path. When bottle_names is non-empty they are resolved from disk and merged in order. When empty, falls back to agent_bottle. Raises ManifestError when neither is set.""" if bottle_names: resolved = [load_bottle_chain_from_dir(bn, bottles_dir) for bn in bottle_names] return merge_bottles_runtime(resolved) if not agent_bottle: raise ManifestError( f"agent '{agent_name}' has no 'bottle' field and no bottles were " f"selected at launch. Select at least one bottle or add " f"'bottle: ' to the agent manifest." ) return load_bottle_chain_from_dir(agent_bottle, bottles_dir) @dataclass(frozen=True) class Manifest: """Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent(). `bottle` is the effective bottle with the agent's `author` already populated into its git identity. `forge_associations` holds the distinct forge aliases referenced by the effective bottle's git-gate repos, resolved against the agent's `forge-accounts`. Backends and provisioners use this directly — no agent_name lookup needed.""" agent: ManifestAgent bottle: ManifestBottle forge_associations: tuple[ResolvedForgeAssociation, ...] = () def git_identity_summary(self) -> str | None: """One-line effective git identity, e.g. `name=claude, email=eric@dideric.is`. Sourced from the agent's `author` block. Returns None when the agent declares no author.""" gu = self.bottle.git_user if gu.is_empty(): return None parts: list[str] = [] if gu.name: parts.append(f"name={gu.name}") if gu.email: parts.append(f"email={gu.email}") return ", ".join(parts) @dataclass(frozen=True) class ManifestIndex: """Multi-agent/bottle collection. The pre-preflight form. In lazy mode (from resolve()/from_md_dirs()) only filenames are scanned; no file content is read. In eager mode (from from_json_obj()) all agents and bottles are pre-parsed. Call load_for_agent() to get a single-value Manifest ready for backend use.""" bottles: Mapping[str, ManifestBottle] agents: Mapping[str, ManifestAgent] # Set by from_md_dirs; None in from_json_obj (test/programmatic) mode. # Stores the manifest root dirs so load_for_agent can locate files later. home_md: Path | None = field(default=None) cwd_md: Path | None = field(default=None) @classmethod def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex": """Walk the per-file manifest tree and build a ManifestIndex. Layout: $HOME/.bot-bottle/bottles/.md — bottles (home-only) $HOME/.bot-bottle/agents/.md — agents (home-only) Both agents and bottles are home-only (PRD prd-new-trusted-agent-forge-identity): a `bottles/` or `agents/` subdir under $CWD is logged as a warning and ignored — the filesystem layout IS the trust boundary, since an agent may now select a host identity and forge secret. If `missing_ok` is true, a missing `$HOME/.bot-bottle/` returns an empty index instead of dying. This is for passive UI surfaces like the dashboard, which can still monitor already-running agents without launch config. If `bot-bottle.json` exists alongside a missing `.bot-bottle/` directory at either side, dies with a clear pointer at the README's manifest section — the manifest format changed in PRD 0011 and we don't silently fall back.""" home_dir = Path(os.environ["HOME"]) cwd_dir = Path(cwd) home_md = home_dir / ".bot-bottle" cwd_md = cwd_dir / ".bot-bottle" check_stale_json(home_dir, home_md, "$HOME") if cwd_dir.resolve() != home_dir.resolve(): check_stale_json(cwd_dir, cwd_md, "$CWD") if not home_md.is_dir(): if missing_ok: return cls.from_json_obj({"bottles": {}, "agents": {}}) raise ManifestError( f"no manifest found: {home_md} does not exist. " f"See README.md for the per-file Markdown layout " f"(PRD 0011)." ) # When CWD == HOME (running from $HOME directly), pass the # same dir for both — _load_md_dirs will dedupe. cwd_md_arg = cwd_md if cwd_md.is_dir() and cwd_dir.resolve() != home_dir.resolve() else None return cls.from_md_dirs(home_md, cwd_md_arg) @classmethod def from_md_dirs( cls, home_dir: Path, cwd_dir: Path | None, ) -> "ManifestIndex": """Return a names-only ManifestIndex. No file content is read; only filenames are scanned for the agent selector. Full parsing happens later, per-agent, via `load_for_agent`. A `bottles/` subdir under `cwd_dir` is logged as a warning and ignored — the filesystem layout IS the trust boundary. Used by tests to build a ManifestIndex from fixture directories without touching `os.environ`.""" if cwd_dir is not None: _warn_ignored_cwd_dir(cwd_dir, "bottles", "$HOME/.bot-bottle/bottles/") # Agents became home-only in # PRD prd-new-trusted-agent-forge-identity: a cwd agent file that # once shadowed a home agent could select a host identity/secret, # so it is now ignored with a migration pointer. _warn_ignored_cwd_dir(cwd_dir, "agents", "$HOME/.bot-bottle/agents/") return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir) @classmethod def from_json_obj(cls, obj: object) -> "ManifestIndex": """Validate and build a ManifestIndex from a raw JSON-like dict.""" d = as_json_object(obj, "manifest") raw_bottles_obj = _section_dict(d.get("bottles"), "manifest 'bottles'") raw_agents = _section_dict(d.get("agents"), "manifest 'agents'") # Coerce each bottle's raw to dict[str, object] so the # PRD 0025 resolver can apply extends-merge rules # consistently with the md-loader path. 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) bottle_names = set(bottles.keys()) agents: dict[str, ManifestAgent] = { n: ManifestAgent.from_dict(n, a, bottle_names) for n, a in raw_agents.items() } return cls(bottles=bottles, agents=agents) @property def all_bottle_names(self) -> list[str]: """Sorted list of all discoverable bottle names. In names-only mode (from resolve/from_md_dirs) this scans bottle filenames without reading their content. In eager mode (from from_json_obj) it returns the pre-parsed bottles' names.""" if self.home_md is not None: return scan_bottle_names(self.home_md / "bottles") return sorted(self.bottles.keys()) @property def all_agent_names(self) -> list[str]: """Sorted list of all discoverable agent names. In names-only mode (from resolve/from_md_dirs) this scans agent filenames without reading their content. In eager mode (from from_json_obj) it returns the pre-parsed agents' names. Agents are home-only (PRD prd-new-trusted-agent-forge-identity): cwd agent files never contribute names.""" if self.home_md is not None: return sorted(scan_agent_names(self.home_md / "agents").keys()) return sorted(self.agents.keys()) def load_for_agent( self, agent_name: str, bottle_names: "tuple[str, ...] | None" = None, ) -> "Manifest": """Parse the named agent and its bottle; return a single-value Manifest. `bottle_names` is an ordered list of bottles selected at launch time. When non-empty they are resolved and merged in order (index 0 = base; later entries override). When empty or None, falls back to the agent's own `bottle:` field. Raises ManifestError when neither is set. In lazy mode (from resolve/from_md_dirs) the agent file and its bottle chain are read from disk for the first time here. In eager mode (from_json_obj) the data is already parsed; this just filters down to the requested agent and its bottle. The returned Manifest.bottle has the agent's git-gate.user already overlaid (agent wins on non-empty, per-field). Always raises ManifestError if the agent is unknown or invalid. Backends call this at preflight inside _validate.""" effective_bottle_names: tuple[str, ...] = bottle_names or () if self.home_md is None: return self._load_for_agent_eager(agent_name, effective_bottle_names) return self._load_for_agent_lazy(agent_name, effective_bottle_names) def _load_for_agent_eager( self, agent_name: str, bottle_names: tuple[str, ...] ) -> "Manifest": """Eager path (from_json_obj): data is already parsed; filter to the one requested agent and its bottle so the returned Manifest always holds exactly one agent and one bottle regardless of path.""" if agent_name not in self.agents: available = ", ".join(sorted(self.agents.keys())) or "(none)" raise ManifestError( f"agent '{agent_name}' not defined. Available: {available}" ) agent = self.agents[agent_name] raw_bottle = _resolve_effective_bottle_eager( agent_name, agent, bottle_names, self.bottles ) return _compose_manifest(agent_name, agent, raw_bottle) def _load_for_agent_lazy( self, agent_name: str, bottle_names: tuple[str, ...] ) -> "Manifest": """Lazy path (resolve/from_md_dirs): read and parse the agent file and its bottle chain from disk for the first time here.""" assert self.home_md is not None # guaranteed by load_for_agent dispatch # Agents are home-only (PRD prd-new-trusted-agent-forge-identity): # a cwd agent file must not select a host identity or forge secret. home_agents = scan_agent_names(self.home_md / "agents") if agent_name not in home_agents: available = ", ".join(sorted(home_agents.keys())) or "(none)" raise ManifestError( f"agent '{agent_name}' not defined. Available: {available}" ) agent_path = home_agents[agent_name] try: fm, body = parse_frontmatter(agent_path.read_text()) except OSError as e: raise ManifestError(f"could not read {agent_path}: {e}") from e except YamlSubsetError as e: raise ManifestError(f"{agent_path}: {e}") from e validate_agent_frontmatter_keys(agent_path, fm.keys()) # Determine the effective bottle name(s). agent_bottle = fm.get("bottle") or "" bottles_dir = self.home_md / "bottles" raw_bottle = _resolve_effective_bottle_lazy( agent_name, str(agent_bottle), bottle_names, bottles_dir ) effective_bottle_name = ( bottle_names[-1] if bottle_names else str(agent_bottle) ) # Build and validate the full ManifestAgent. agent_dict: dict[str, object] = { "skills": fm.get("skills", []), "prompt": body.strip(), } if agent_bottle: agent_dict["bottle"] = agent_bottle # Surface agent-owned identity keys (and any stale git-gate, so # ManifestAgent.from_dict raises the migration error). for key in ("author", "forge-accounts", "git-gate"): if key in fm: agent_dict[key] = fm[key] # Pass the effective bottle name as the known-bottles set so agents # that have bottle: set are validated; agents without bottle: pass {} # since bottle_names were already resolved above. known = {effective_bottle_name} if effective_bottle_name else set() agent = ManifestAgent.from_dict(agent_name, agent_dict, known) return _compose_manifest(agent_name, agent, raw_bottle) def has_agent(self, name: str) -> bool: return name in self.agents def require_agent(self, name: str) -> None: """Check that `name` is a discoverable agent. In names-only mode this checks whether the .md file exists; in eager mode it checks the pre-parsed agents dict. Does NOT parse file content.""" if self.has_agent(name): return if self.home_md is not None: # Names-only mode: check home file existence without parsing. # Agents are home-only; a cwd agent file is never selectable. if (self.home_md / "agents" / f"{name}.md").is_file(): return available = ", ".join(self.all_agent_names) or "(none)" raise ManifestError( f"agent '{name}' not defined. Available: {available}" )