#!/usr/bin/env bash set -euo pipefail usage() { cat < Validate an agent definition file against the agent definition spec. At plugin/APM scope, is a single vendor-neutral .apm/agents/.agent.md file (frontmatter allowlist: name, description, model — no counterpart file). At project or user scope, is either half of a Claude Code .md / Copilot .agent.md pair. Arguments: agent-file Path to the agent file (or either half of a project/user-scope pair). Exit codes: 0 All checks passed (may include SUGGESTIONs) 1 One or more checks failed 2 Script error (unrecognized file extension or missing field-inventory.md) 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 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" python3 -u - "$1" "$SCRIPT_DIR" <<'PYTHON' import sys import os import re agent_file = os.path.abspath(sys.argv[1]) script_dir = sys.argv[2] fname = os.path.basename(agent_file) # --- Detect provider (check .agent.md before .md) --- if fname.endswith('.agent.md'): provider = 'copilot' name_stem = fname[:-len('.agent.md')] elif fname.endswith('.md'): provider = 'claude-code' name_stem = fname[:-len('.md')] else: print(f"Error: unrecognized extension '{fname}' — expected .md or .agent.md", file=sys.stderr) sys.exit(2) # --- Load field-inventory.md --- inv_path = os.path.normpath(os.path.join(script_dir, '..', 'references', 'field-inventory.md')) if not os.path.isfile(inv_path): print(f"Error: field-inventory.md not found at {inv_path}", file=sys.stderr) sys.exit(2) with open(inv_path) as f: inv_content = f.read() def parse_section_tokens(content, section_name): lines = content.splitlines() for i, line in enumerate(lines): if line.strip() == f'## {section_name}': for j in range(i + 1, len(lines)): stripped = lines[j].strip() if stripped and not stripped.startswith('#') and not stripped.startswith('---'): return set(stripped.split()) return set() cc_only_fields = parse_section_tokens(inv_content, 'claude-code-only-fields') copilot_only_fields = parse_section_tokens(inv_content, 'copilot-only-fields') apm_agent_allowlist = parse_section_tokens(inv_content, 'apm-agent-allowlist') # Tools the runtime withholds from subagents regardless of the tools field SUBAGENT_UNAVAILABLE_TOOLS = { 'AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode', 'ScheduleWakeup', 'WaitForMcpServers', } # Copilot body length limit (chars) — content beyond this is silently truncated COPILOT_BODY_LIMIT = 30000 # --- Helpers (shared by every scope) --- failed = False suggestions = [] def fail(msg): global failed failed = True print(f"FAIL {msg}") def suggest(msg): suggestions.append(msg) PLACEHOLDER_RE = re.compile(r'(?.agent.md) name_val = extract_field(fm, 'name') if not name_val: fail(f"name field is missing or empty — {local_fname}") else: if not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name_val): fail(f"name '{name_val}' is not kebab-case — {local_fname}") if name_val != stem: fail(f"name '{name_val}' does not match filename stem '{stem}' — {local_fname}") # description — required, non-empty, no placeholder desc_val = extract_field(fm, 'description') if not desc_val: fail(f"description field is missing or empty — {local_fname}") else: if PLACEHOLDER_RE.search(desc_val): fail(f"description contains unfilled FILL IN: placeholder — {local_fname}") # body — required, non-empty, no placeholder; same Copilot truncation risk # applies since this file compiles verbatim into a real Copilot file downstream. if not body.strip(): fail(f"system prompt body is empty — {local_fname}") else: if PLACEHOLDER_RE.search(body): fail(f"body contains unfilled FILL IN: placeholder — {local_fname}") if len(body) > COPILOT_BODY_LIMIT: suggest(f"body exceeds {COPILOT_BODY_LIMIT:,} characters ({len(body):,} chars) — " f"content beyond the limit is silently truncated by the Copilot runtime " f"once apm compile emits it downstream — {local_fname}") if scope == 'plugin': check_apm_agent_file(agent_file, apm_agent_allowlist, name_stem) for s in suggestions: print(f"SUGGESTION {s}") sys.exit(1 if failed else 0) # --- Project/user scope: unchanged CC/Copilot pair validation --- # --- Derive counterpart path --- if scope == 'project': if provider == 'claude-code': counterpart = os.path.join(scope_root, '.github', 'agents', name_stem + '.agent.md') counterpart_provider = 'copilot' else: counterpart = os.path.join(scope_root, '.claude', 'agents', name_stem + '.md') counterpart_provider = 'claude-code' else: # user home = os.path.expanduser('~') if provider == 'claude-code': counterpart = os.path.join(home, '.copilot', 'agents', name_stem + '.agent.md') counterpart_provider = 'copilot' else: counterpart = os.path.join(home, '.claude', 'agents', name_stem + '.md') counterpart_provider = 'claude-code' def check_file(fpath, file_provider): local_fname = os.path.basename(fpath) with open(fpath) as f: content = f.read() fm, body = parse_frontmatter(content) if fm is None: fail(f"no valid YAML frontmatter (---...---) — {local_fname}") return # name — required for CC and Copilot CLI; optional for Copilot cloud/IDE agents cloud_ide = (file_provider == 'copilot' and is_copilot_cloud_ide(fpath)) name_val = extract_field(fm, 'name') if not cloud_ide: if not name_val: fail(f"name field is missing or empty — {local_fname}") else: if not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name_val): fail(f"name '{name_val}' is not kebab-case — {local_fname}") # Stem check applies to Copilot CLI only; CC docs say filename need not match name if file_provider == 'copilot': stem = local_fname[:-len('.agent.md')] if name_val != stem: fail(f"name '{name_val}' does not match filename stem '{stem}' — {local_fname}") elif name_val and not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name_val): # cloud/IDE: name is optional, but if present it must be valid fail(f"name '{name_val}' is not kebab-case — {local_fname}") # description desc_val = extract_field(fm, 'description') if not desc_val: fail(f"description field is missing or empty — {local_fname}") else: if PLACEHOLDER_RE.search(desc_val): fail(f"description contains unfilled FILL IN: placeholder — {local_fname}") # body if not body.strip(): fail(f"system prompt body is empty — {local_fname}") else: if PLACEHOLDER_RE.search(body): fail(f"body contains unfilled FILL IN: placeholder — {local_fname}") # Copilot body length limit if file_provider == 'copilot' and len(body) > COPILOT_BODY_LIMIT: suggest(f"body exceeds {COPILOT_BODY_LIMIT:,} characters ({len(body):,} chars) — content beyond the limit is silently truncated by the Copilot runtime — {local_fname}") # CC-only fields in Copilot file if file_provider == 'copilot': fm_keys = get_frontmatter_keys(fm) for key in sorted(fm_keys): if key in cc_only_fields: fail(f"CC-only field '{key}' present in Copilot file — {local_fname}") # Copilot-only fields in CC file if file_provider == 'claude-code': fm_keys = get_frontmatter_keys(fm) for key in sorted(fm_keys): if key in copilot_only_fields: fail(f"Copilot-only field '{key}' present in CC file — {local_fname}") # Subagent-unavailable tools listed in tools field tools = extract_tools_list(fm) unavailable = tools & SUBAGENT_UNAVAILABLE_TOOLS for tool in sorted(unavailable): suggest(f"'{tool}' is listed in tools but is never available to subagents — the runtime withholds it regardless — {local_fname}") # --- Check counterpart exists --- if not os.path.isfile(counterpart): fail(f"counterpart file not found: {counterpart}") sys.exit(1) # --- Check both files --- check_file(agent_file, provider) check_file(counterpart, counterpart_provider) for s in suggestions: print(f"SUGGESTION {s}") sys.exit(1 if failed else 0) PYTHON