a845cba925
tracker-policy-pr / check-pr (pull_request) Failing after 11s
test / integration-docker (pull_request) Successful in 20s
lint / lint (push) Successful in 59s
test / unit (pull_request) Successful in 2m10s
test / integration-firecracker (pull_request) Successful in 3m29s
test / coverage (pull_request) Successful in 38s
test / publish-infra (pull_request) Has been skipped
Move the nine root-level manifest modules into a bot_bottle/manifest/ package, dropping the redundant manifest_ prefix: the facade (manifest.py) becomes the package __init__ and keeps re-exporting Manifest / ManifestIndex and the piece types, while manifest_<x>.py become manifest/<x>.py (agent, bottle, egress, extends, git, loader, schema, util). Because the facade stays the package __init__, the ~13 callers that import `from bot_bottle.manifest import ...` are unchanged; only direct-piece imports move to `bot_bottle.manifest.<name>`. Inside the package, intra-manifest imports drop the prefix and the non-manifest siblings (log, yaml_subset, agent_provider) move to `..`. No behavior change; full unit suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
81 lines
2.6 KiB
Python
81 lines
2.6 KiB
Python
"""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-gate", "egress",
|
|
"supervise", "nested_containers",
|
|
}
|
|
)
|
|
AGENT_KEYS_REQUIRED: frozenset[str] = frozenset()
|
|
AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "git-gate"})
|
|
|
|
# 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 is_valid_entity_name(name: str) -> bool:
|
|
"""True if `name` fits the kebab-case `[a-z][a-z0-9-]*` convention
|
|
shared by bottle/agent filenames and skill names. Names that satisfy
|
|
this are also safe to interpolate into a host/guest path segment."""
|
|
return bool(_FILENAME_RX.match(name))
|
|
|
|
|
|
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 is_valid_entity_name(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 .util import ManifestError
|
|
|
|
key_set = set(keys) # type: ignore
|
|
unknown = key_set - allowed_keys # type: ignore
|
|
if unknown:
|
|
allowed = ", ".join(sorted(allowed_keys))
|
|
raise ManifestError(
|
|
f"{kind} file {path}: unknown frontmatter key(s) "
|
|
f"{sorted(unknown)}; allowed keys are {allowed}." # type: ignore
|
|
)
|