#!/usr/bin/env bash set -euo pipefail SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL_ROOT="$(cd "$SKILL_DIR/.." && pwd)" TEMPLATES_DIR="$SKILL_ROOT/assets/templates" usage() { cat < Scaffold agent definition file(s) for Claude Code, GitHub Copilot CLI, and/or vendor-neutral APM packages. Arguments: agent-name Kebab-case agent identifier (e.g. code-reviewer, deploy-assistant). root Starting directory — scope is resolved by walking up from here: plugin/APM scope : nearest ancestor (at/above root) whose apm.yml has a top-level type: field (instructions, skill, hybrid, or prompts) — an apm.yml without type: is a marketplace-only manifest and is skipped, the walk continues upward → creates /.apm/agents/.agent.md (single vendor-neutral file; apm compile copies its frontmatter verbatim to every target with no per-target field integrator, so the permitted field set is narrow — see the apm-agent-allowlist section of agent-audit's references/field-inventory.md and ADR-0016) → creates /sources.md (if absent) project scope : no type:-bearing apm.yml found; root is a project directory → creates /.claude/agents/.md → creates /.github/agents/.agent.md user scope : root is exactly ~ (home directory; checked directly, no walk-up) → creates ~/.claude/agents/.md → creates ~/.copilot/agents/.agent.md Each file is created only if it does not already exist (no-op per file). Exit codes: 0 Files created or already existed (no-op) 1 Invalid arguments, missing root, or templates not found EOF } if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then usage exit 0 fi if [[ $# -lt 2 ]]; then echo "Error: agent-name and root are required." >&2 echo "" >&2 usage >&2 exit 1 fi AGENT_NAME="$1" ROOT="$2" # Validate agent name format if ! echo "$AGENT_NAME" | grep -qE '^[a-z0-9]+(-[a-z0-9]+)*$'; then echo "Error: agent-name must use lowercase letters, numbers, and hyphens only." >&2 echo " No leading, trailing, or consecutive hyphens." >&2 echo " Received: '$AGENT_NAME'" >&2 exit 1 fi # Validate templates directory if [[ ! -d "$TEMPLATES_DIR" ]]; then echo "Error: templates directory not found at '$TEMPLATES_DIR'." >&2 echo " Run this script from its original location inside the agent-author skill." >&2 exit 1 fi # Expand tilde ROOT="${ROOT/#\~/$HOME}" # Validate root exists if [[ ! -d "$ROOT" ]]; then echo "Error: root directory '$ROOT' does not exist." >&2 exit 1 fi ROOT="$(cd "$ROOT" && pwd)" # True if apm_yml's top-level `type:` line names one of the four APM package # types (instructions/skill/hybrid/prompts) — mirrors validate.sh's # APM_TYPE_RE: an optional quote around the value must be closed by the # *same* quote character (a mismatched or unterminated quote is rejected, # not silently stripped), and the value must be followed by whitespace or # end-of-line so `prompts-only` doesn't false-match on the `prompts` prefix. # `|| [[ -n "$line" ]]` in the read condition also processes a final line # that lacks a trailing newline, which `read` alone would otherwise skip. is_apm_package_manifest() { local apm_yml="$1" line while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" =~ ^type:[[:space:]]*(instructions|skill|hybrid|prompts)([[:space:]]|$) ]]; then return 0 fi if [[ "$line" =~ ^type:[[:space:]]*([\"\'])(instructions|skill|hybrid|prompts)([\"\'])([[:space:]]|$) ]] \ && [[ "${BASH_REMATCH[1]}" == "${BASH_REMATCH[3]}" ]]; then return 0 fi done < "$apm_yml" return 1 } # --- Walk-up package-root detection --- # # Mirrors agent-audit's validate.sh scope walk-up, with apm.yml + type: swapped # in for the old plugin.json marker. Starting at ROOT, walk upward: # - an apm.yml with a top-level `type:` field marks an APM package root # (plugin/APM scope) — stop and return it. # - an apm.yml with no `type:` field is a marketplace-only manifest — skip # it, keep walking up. # - user scope is checked directly at $HOME, no walk-up (see usage text # above): ROOT itself being $HOME resolves to user scope, even if $HOME # is itself a .git-tracked dotfiles directory (checked before the .git # test below, so a dotfiles repo at $HOME can't shadow user scope). # Walking *up into* $HOME from a nested directory with no apm.yml/.git # of its own does NOT promote to user scope — it resolves to project # scope instead, same as any other unmatched boundary, so a stray # directory under $HOME can't be silently redirected into the shared # global ~/.claude or ~/.copilot agent directories. # - a .git file or directory marks the project-scope boundary (a worktree's # .git is a file, not a directory) — stop. # - filesystem root reached with neither found — project scope, same as # any other unmatched boundary. find_package_root() { local root="$1" current="$1" while true; do if [[ -f "$current/apm.yml" ]] && is_apm_package_manifest "$current/apm.yml"; then echo "plugin $current" return fi if [[ "$current" == "$HOME" ]]; then if [[ "$current" == "$root" ]]; then echo "user $current" return fi echo "project $current" return fi if [[ -e "$current/.git" ]]; then echo "project $current" return fi local parent parent="$(dirname "$current")" if [[ "$parent" == "$current" ]]; then echo "project $current" return fi current="$parent" done } # `read` consumes a single line, so kind and path are emitted on one # space-separated line rather than two `echo`s — kind first (never contains # spaces), path last (absorbs any spaces in the path safely). WALK_RESULT="$(find_package_root "$ROOT")" read -r WALK_KIND WALK_ROOT <<< "$WALK_RESULT" PACKAGE_ROOT="" case "$WALK_KIND" in plugin) SCOPE="plugin" PACKAGE_ROOT="$WALK_ROOT" ;; user) SCOPE="user" ;; project) SCOPE="project" ;; esac # Determine file destinations case "$SCOPE" in plugin) APM_DIR="$PACKAGE_ROOT/.apm/agents" SOURCES_DIR="$PACKAGE_ROOT" ;; project) CC_DIR="$ROOT/.claude/agents" CP_DIR="$ROOT/.github/agents" SOURCES_DIR="" ;; user) CC_DIR="$HOME/.claude/agents" CP_DIR="$HOME/.copilot/agents" SOURCES_DIR="" ;; esac created_any=false if [[ "$SCOPE" == "plugin" ]]; then APM_FILE="$APM_DIR/$AGENT_NAME.agent.md" mkdir -p "$APM_DIR" if [[ -f "$APM_FILE" ]]; then echo "Skipping '$APM_FILE' — already exists." >&2 else sed "s/AGENT_NAME/$AGENT_NAME/g" "$TEMPLATES_DIR/apm-agent.md" > "$APM_FILE" echo "Created: $APM_FILE" >&2 created_any=true fi else CC_FILE="$CC_DIR/$AGENT_NAME.md" CP_FILE="$CP_DIR/$AGENT_NAME.agent.md" mkdir -p "$CC_DIR" mkdir -p "$CP_DIR" # Copy Claude Code template (no-op if exists) if [[ -f "$CC_FILE" ]]; then echo "Skipping '$CC_FILE' — already exists." >&2 else sed "s/AGENT_NAME/$AGENT_NAME/g" "$TEMPLATES_DIR/claude-code.md" > "$CC_FILE" echo "Created: $CC_FILE" >&2 created_any=true fi # Copy Copilot template (no-op if exists) if [[ -f "$CP_FILE" ]]; then echo "Skipping '$CP_FILE' — already exists." >&2 else sed "s/AGENT_NAME/$AGENT_NAME/g" "$TEMPLATES_DIR/copilot.agent.md.template" > "$CP_FILE" echo "Created: $CP_FILE" >&2 created_any=true fi fi # Create sources.md at plugin/APM package root (no-op if exists) if [[ -n "$SOURCES_DIR" ]]; then SOURCES_FILE="$SOURCES_DIR/sources.md" if [[ -f "$SOURCES_FILE" ]]; then echo "Skipping '$SOURCES_FILE' — already exists." >&2 else cat > "$SOURCES_FILE" <<'SOURCES' # Sources SOURCES echo "Created: $SOURCES_FILE" >&2 created_any=true fi fi # Next-steps guidance names no frontmatter fields, by rule (see references/scripts.md). # A roster restated in terminal output goes stale one step further out than the list # itself: the old "(name, description, model, body only)" hint outlived ADR-0016's # 2026-08-14 amendment, which added disallowedTools to the permitted set. Point at the # scaffolded file's own comments for what to fill, and at agent-audit's validate.sh — # which reads the allowlist from field-inventory.md as data — for what is permitted. AUDIT_SCRIPTS="$(cd "$SKILL_ROOT/../agent-audit/scripts" 2>/dev/null && pwd || true)" if [[ -n "$AUDIT_SCRIPTS" && -f "$AUDIT_SCRIPTS/validate.sh" ]]; then VALIDATE_HINT="$AUDIT_SCRIPTS/validate.sh" else VALIDATE_HINT="agent-audit's scripts/validate.sh" fi if [[ "$created_any" == false ]]; then echo "All files already exist — nothing to do." >&2 else echo "" >&2 echo "Scope: $SCOPE" >&2 echo "" >&2 echo "Next steps:" >&2 if [[ "$SCOPE" == "plugin" ]]; then echo " 1. Fill in $APM_FILE — replace every FILL IN: placeholder. Optional fields are" >&2 echo " scaffolded there as commented blocks; uncomment the ones that apply." >&2 echo " 2. Populate $SOURCES_DIR/sources.md with research sources, or delete it" >&2 echo " 3. Validate: $VALIDATE_HINT $APM_FILE" >&2 echo " It checks the frontmatter against the apm-agent-allowlist section of" >&2 echo " agent-audit's references/field-inventory.md, the authoritative field list." >&2 else echo " 1. Fill in $CC_FILE — replace every FILL IN: placeholder. Optional fields are" >&2 echo " scaffolded there as commented blocks; uncomment the ones that apply." >&2 echo " 2. Fill in $CP_FILE — same, and heed its closing comment: the Claude Code-only" >&2 echo " fields it names must not cross over from the file above." >&2 echo " 3. Validate: run $VALIDATE_HINT on each file" >&2 fi fi