#!/usr/bin/env bash set -euo pipefail usage() { cat < Validate that an agent's sources provenance chain is complete and internally consistent. Operates at plugin/APM scope only (a single vendor-neutral .apm/agents/.agent.md inside a package with a type:-bearing apm.yml) — exits 0 silently for project and user scope agents. Arguments: agent-file Path to either the Claude Code .md or Copilot .agent.md agent file. Exit codes: 0 All checks passed (or nothing to validate, or not plugin scope) 1 One or more checks failed 2 Script error (unrecognized file extension — expected .md or .agent.md) Checks performed: 0 source_keys present in agent pair but sources.md absent 1 FILL IN: placeholders in sources.md 2 source_keys in agent files → slug exists in sources.md 3 Contributing files listed in sources.md exist on disk (plugin-root relative) 4 Contributing files back-reference the parent slug in their source_keys 5 Research doc field present and not placeholder EOF } if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then usage exit 0 fi if [[ $# -lt 1 ]]; then echo "Error: agent-file is required." >&2 echo "" >&2 usage >&2 exit 1 fi python3 -u - "$1" <<'PYTHON' import sys import os import re agent_file = os.path.abspath(sys.argv[1]) fname = os.path.basename(agent_file) agent_dir = os.path.dirname(agent_file) # --- Sanity-check extension (single vendor-neutral .agent.md file at plugin/APM scope) --- if not (fname.endswith('.agent.md') or fname.endswith('.md')): print(f"Error: unrecognized extension '{fname}' — expected .md or .agent.md", file=sys.stderr) sys.exit(2) # Matches a top-level `type:` line whose value is exactly one of the four # package content types — identical to validate.sh's APM_TYPE_RE. Group 1's # optional quote must be closed by \1 (or nothing), and the value must be # followed by whitespace/end-of-line so a malformed value like `prompts-only` # doesn't false-match on the `prompts` prefix. TYPE_RE = re.compile(r"^type:\s*(['\"]?)(instructions|skill|hybrid|prompts)\1(?:\s|$)") # --- Find package root: walk up for the nearest ancestor apm.yml that # declares a top-level type: field. An apm.yml with no type: field is a # marketplace-only manifest (see monorepo-and-repo-shapes.md) — skip it and # keep walking. Stop at a $HOME boundary, a .git boundary, or the filesystem # root: none of these is plugin/APM scope, so this script has nothing to # check there. def find_plugin_root(start_dir): home = os.path.expanduser('~') current = os.path.abspath(start_dir) while True: apm_yml = os.path.join(current, 'apm.yml') if os.path.isfile(apm_yml): with open(apm_yml) as f: if any(TYPE_RE.match(line) for line in f): return current # $HOME is a non-plugin-scope boundary — checked before the .git test # below (mirrors validate.sh's detect_scope ordering), so a # dotfiles-managed $HOME (yadm, chezmoi bare-repo, etc.) can't shadow # this check by being its own .git repo. Without this, the walk could # continue past $HOME toward the filesystem root looking for a # type-bearing apm.yml, misclassifying a user/project-scope file as # plugin scope in rare ancestor layouts. if current == home: return None # .git is a directory in a normal checkout but a file (`gitdir: ...`) # in a git worktree — exists() covers both. if os.path.exists(os.path.join(current, '.git')): return None parent = os.path.dirname(current) if parent == current: return None current = parent plugin_root = find_plugin_root(agent_dir) if plugin_root is None: sys.exit(0) sources_md_path = os.path.join(plugin_root, 'sources.md') # --- Helpers --- PLACEHOLDER_RE = re.compile(r'(?' to the '## {slug}' entry in sources.md." ) elif rd_value == "" or PLACEHOLDER_RE.search(rd_value): emit_fail( "Research doc field is empty or placeholder", f"sources.md (## {slug})", f"The '## {slug}' entry has an unfilled Research doc value.", "Set '- **Research doc:**' to a real path relative to repo root, or '(none)' if not applicable." ) print_findings() sys.exit(1 if has_fail else 0) PYTHON