Claude Code's (and Copilot's) native plugin installer has zero awareness of .apm/ nesting -- it convention-scans only flat skills/, agents/, commands/, hooks.json at each plugin's root. Confirmed via strings on the installed claude binary and live installs of git@holocron/gitea@holocron/kyberforge@ holocron, all reporting Skills(0) Agents(0) Hooks(0) post ADR-0015's apm conversion. Root cause (apm_cli/core/plugin_manifest.py): apm's plugin.json compiler deliberately strips skills/agents/commands keys, assuming the host already auto-discovers those convention directories -- it has no model of .apm/ being host-visible at all. Separately, apm's own bundle exporter (apm_cli/bundle/plugin_exporter.py, behind `apm pack --format plugin`) implements the correct .apm/ -> flat mapping, but only ever targeted build/<name>-<version>/, a path nothing in marketplace.json's source: points at. scripts/sync-plugin-content.sh wraps that bundle exporter and copies its agents/, skills/, commands/, instructions/, extensions/, and merged hooks.json back into each plugin's own root as a second tracked compiled-output category -- same governance status as .claude-plugin/plugin.json: generated from .apm/, never hand-edited. tests/ subdirectories are excluded from the mirror (dev fixtures, not host-visible runtime content; several hardcode a relative repo-root walk-up sized for the .apm/-nested depth, which breaks when duplicated one level shallower). Applied for real across all 6 plugins and verified two ways: `claude plugin validate --strict` passes on every real plugin directory, and a live `claude --plugin-dir <path> -p "list skills/agents"` behavioral test confirms content is now actually discovered. Also, from the same issue #90 review round: - scripts/check-manifests.sh pointed at each plugin's root-level plugin.json (checking skills/hooks/mcpServers/agents pointer fields) -- that file was a stale near-duplicate of .claude-plugin/plugin.json nothing else read or wrote, now deleted across all 6 plugins. check-manifests.sh is rewritten to validate .claude-plugin/plugin.json instead, and drops the pointer-field checks entirely (nothing to check -- those fields are correctly absent by design). Content-presence drift is now check-plugin-content-sync's job, a new pre-push hook wired in .pre-commit-config.yaml. docs/adr/0017 records the root cause and decision in full, including two rejected alternatives (patching plugin.json's path fields directly -- apm's compiler strips them on every run; pointing marketplace.json at apm pack's build/ output -- a version-suffixed non-source directory nothing can install from without an extra build step). ADR-0015 and CONTEXT.md are updated to point at it. Refs: #90
119 lines
4.3 KiB
Bash
Executable File
119 lines
4.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: validate-structure.sh <repo-root>
|
|
|
|
Check every AGENTS.md file in a repo (root and nested) for structural
|
|
completeness against the agents.md spec's common-sections checklist
|
|
(setup/build, code style, testing, security, commit/PR conventions).
|
|
Missing individual sections are informational (not every repo needs every
|
|
section) — only an empty or entirely unfilled file is a hard failure.
|
|
|
|
Arguments:
|
|
repo-root Path to the repository root to scan.
|
|
|
|
Exit codes:
|
|
0 No FAIL findings (INFO/SUGGESTION may still be printed)
|
|
1 One or more FAIL findings
|
|
EOF
|
|
}
|
|
|
|
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
|
usage
|
|
exit 0
|
|
fi
|
|
|
|
if [[ $# -lt 1 ]]; then
|
|
echo "Error: repo-root is required." >&2
|
|
echo "" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
|
|
python3 -u - "$1" <<'PYTHON'
|
|
import sys
|
|
import os
|
|
import re
|
|
|
|
PLACEHOLDER_RE = re.compile(r'(?i)FILL IN:|TODO:\s*write|lorem ipsum')
|
|
|
|
COMMON_SECTIONS = [
|
|
("setup/build commands", re.compile(r'(?im)^#{1,3}\s*(setup|install|build|getting started)')),
|
|
("code style", re.compile(r'(?im)^#{1,3}\s*(code style|style guide|conventions)')),
|
|
("testing instructions", re.compile(r'(?im)^#{1,3}\s*(test|testing)')),
|
|
("security considerations", re.compile(r'(?im)^#{1,3}\s*security')),
|
|
("commit/PR conventions", re.compile(r'(?im)^#{1,3}\s*(commit|pr|pull request)')),
|
|
]
|
|
|
|
repo_root = os.path.abspath(sys.argv[1])
|
|
if not os.path.isdir(repo_root):
|
|
print(f"Error: '{repo_root}' is not a directory.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
EXCLUDE_DIRS = {".git", "node_modules", "vendor", ".venv", "venv", "dist", "build"}
|
|
|
|
def find_agents_md(root):
|
|
results = []
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS and not d.startswith(".")]
|
|
for fname in filenames:
|
|
if fname == "AGENTS.md":
|
|
results.append(os.path.join(dirpath, fname))
|
|
return sorted(results)
|
|
|
|
has_fail = False
|
|
file_contents = {} # rel path -> content, for the duplication pass below
|
|
|
|
for fpath in find_agents_md(repo_root):
|
|
rel = os.path.relpath(fpath, repo_root)
|
|
with open(fpath, encoding="utf-8", errors="replace") as f:
|
|
content = f.read()
|
|
file_contents[rel] = content
|
|
|
|
if not content.strip():
|
|
has_fail = True
|
|
print(f"FAIL AGENTS.md is empty — {rel}")
|
|
print(" Why: An empty file provides no instructions and gives agents nothing to act on.")
|
|
print(" Fix: Add at least a project overview and setup/test commands, per the agents.md common-sections checklist.")
|
|
print()
|
|
continue
|
|
|
|
if PLACEHOLDER_RE.search(content):
|
|
has_fail = True
|
|
print(f"FAIL Unfilled placeholder content — {rel}")
|
|
print(" Why: A 'FILL IN:' or template stub left in place means the file has no repo-specific instructions yet.")
|
|
print(" Fix: Replace the placeholder with real, repo-specific content.")
|
|
print()
|
|
continue
|
|
|
|
for label, pattern in COMMON_SECTIONS:
|
|
if not pattern.search(content):
|
|
print(f"INFO No {label} section — {rel}")
|
|
print(f" Note: The agents.md common-sections checklist includes {label}; not every repo needs every section, but confirm this omission is deliberate.")
|
|
print()
|
|
|
|
# --- Nested-vs-root duplication check ---
|
|
root_content = file_contents.get("AGENTS.md")
|
|
if root_content:
|
|
root_lines = {ln.strip() for ln in root_content.splitlines() if ln.strip()}
|
|
for rel, content in file_contents.items():
|
|
if rel == "AGENTS.md":
|
|
continue
|
|
nested_lines = [ln.strip() for ln in content.splitlines() if ln.strip()]
|
|
if not nested_lines:
|
|
continue
|
|
overlap = sum(1 for ln in nested_lines if ln in root_lines)
|
|
ratio = overlap / len(nested_lines)
|
|
if ratio >= 0.7:
|
|
print(f"SUGGESTION Nested AGENTS.md largely duplicates the root file — {rel}")
|
|
print(f" Why: {ratio:.0%} of this file's content lines already appear in the root AGENTS.md; per the spec's nearest-file-wins precedence, nested files don't inherit from the root, but they also shouldn't just restate it.")
|
|
print(f" Fix: Trim {rel} down to only what's specific to this package/directory.")
|
|
print()
|
|
|
|
if has_fail:
|
|
sys.exit(1)
|
|
sys.exit(0)
|
|
PYTHON
|