Why: ADR-0015 established that Microsoft APM (apm.yml + .apm/) should replace this repo's hand-authored plugin.json/marketplace.json model, with those files becoming compiled output of `apm pack` instead of files edited by hand via the (now-retired) plugin-author/marketplace-author skills. Issue #90 was the deferred execution of that decision, gated on #88 (apm tooling) and #89 (apm-native agent-author/skill-author routing). Implementation notes: - All six plugins (bin, core, git, gitea, kyberforge, lint) now carry apm.yml + .apm/{skills,agents,hooks} as their authoring source. Skills moved with a plain git mv (content-identical across targets). Agents were re-authored, not moved: per ADR-0016, .apm/agents/*.agent.md compiles verbatim to both Claude and Copilot, so plugin-scope agents now carry only name/description/model/source_keys -- no tools: field, no Claude-only knobs (isolation, maxTurns, effort, memory, permissionMode). - Root apm.yml registers all 7 marketplace packages (6 local plus mattpocock-skills as a remote entry) under versioning: per_package, matching this repo's existing independent-plugin-versioning practice. - .claude-plugin/marketplace.json and every plugin's plugin.json are now apm-pack-compiled output, verified against the prior hand-maintained content: same names/descriptions/versions/licenses/authors, only cosmetic serialization differences (JSON key order, owner email vs. url, Unicode escaping). - plugin-author and marketplace-author are retired now that apm-based authoring fully replaces their job; kyberforge bumped 1.3.1 -> 1.4.0 for that removal, and the root marketplace catalog bumped 0.3.1 -> 0.3.2 to match, per the version-bump convention now documented in apm-workflow's reference docs instead of a dedicated script (apm has no native version-bump automation). - Fixed hardcoded pre-.apm/ path assumptions across .pre-commit-config.yaml, .pre-commit-hooks.yaml, scripts/check-scope-walkup-sync.sh, scripts/sync-vale-styles.sh, scripts/check-vale-style-sync.sh, six plugins' root plugin.json (stale skills/hooks/agents pointer fields that check-manifests.sh validates), and several tests/*.bats and tests/*.sh fixtures -- including a bats REPO_ROOT relative-path depth bug (10 files, one extra .apm/ directory level to walk up) and a vale probe-path isolation regression introduced mid-fix. - Corrected empirically-wrong assumptions surfaced this session in apm-workflow/apm-install's own reference docs: `apm marketplace package add` does not accept local paths (only owner/repo remote shorthand -- local packages are registered by editing apm.yml's marketplace.packages[] directly); `apm compile` is a consumer-side AGENTS.md/CLAUDE.md generator, not the plugin.json producer, and hard-fails on skill/agent-only packages without --clean; `apm plugin init <name>` nests a stray subdirectory when run with a positional name arg from inside a same-named directory; no native Copilot marketplace output profile exists; .mcp.json is merged into the compiled plugin.json content-aware and target-scoped, with no dependencies.mcp entry needed for simple passthrough; pipx is the correct pip fallback on externally-managed Python environments. - Renamed agent-author's copilot.agent.md template asset to copilot.agent.md.template so apm compile's recursive *.agent.md glob stops misparsing the placeholder template as a real agent primitive. Impact: plugin.json and marketplace.json are compiled artifacts from here on -- editing them by hand is no longer the workflow; edit apm.yml/.apm/ and run apm pack. CONTEXT.md's Plugin/Plugin marketplace glossary entries reflect this. ADR-0001 is marked superseded, ADR-0006 moot, and ADR-0010 updated for the new .apm/agents/ path (project/user scope unaffected, per ADR-0016). Full local verification: claude plugin validate --strict on all 6 plugins, apm audit --ci, apm marketplace check, check-manifests.sh, and the full test suite (165/165 bats, 13/13 shell scripts) all pass clean. Fixes: #90 Refs: #88, #89 ADR: 0015 ADR: 0016 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ub96PyaSRD9BHPktotj1pC
200 lines
5.9 KiB
Bash
Executable File
200 lines
5.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: validate.sh <skill-dir>
|
|
|
|
Validate a skill directory against the agentskills.io specification.
|
|
|
|
Arguments:
|
|
skill-dir Path to the skill directory containing SKILL.md.
|
|
|
|
Exit codes:
|
|
0 All checks passed
|
|
1 One or more checks failed
|
|
EOF
|
|
}
|
|
|
|
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
|
usage
|
|
exit 0
|
|
fi
|
|
|
|
if [[ $# -lt 1 ]]; then
|
|
echo "Error: skill-dir is required." >&2
|
|
echo "" >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
|
|
python3 -u - "$1" <<'PYTHON'
|
|
import sys
|
|
import os
|
|
import re
|
|
|
|
skill_dir = os.path.abspath(sys.argv[1])
|
|
skill_md = os.path.join(skill_dir, "SKILL.md")
|
|
|
|
if not os.path.isfile(skill_md):
|
|
print(f"Error: '{skill_md}' not found.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
with open(skill_md) as f:
|
|
content = f.read()
|
|
|
|
failed = False
|
|
|
|
def ok(msg):
|
|
print(f"PASS {msg}")
|
|
|
|
def fail(msg):
|
|
global failed
|
|
print(f"FAIL {msg}")
|
|
failed = True
|
|
|
|
# --- Parse frontmatter ---
|
|
fm_match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
|
if not fm_match:
|
|
fail("No valid YAML frontmatter block found (expected ---...---)")
|
|
sys.exit(1)
|
|
|
|
fm = fm_match.group(1)
|
|
body_start = fm_match.end()
|
|
|
|
# Extract name
|
|
name_m = re.search(r'^name:\s*(\S+)', fm, re.MULTILINE)
|
|
name = name_m.group(1).strip('"\'') if name_m else ""
|
|
|
|
# Extract description — inline or block scalar (> or |)
|
|
desc = ""
|
|
desc_m = re.search(r'^description:\s*([>|])\n((?:[ \t]+.+\n?)+)', fm, re.MULTILINE)
|
|
if desc_m:
|
|
raw = desc_m.group(2)
|
|
desc = re.sub(r'\s+', ' ', raw).strip()
|
|
else:
|
|
desc_inline = re.search(r'^description:\s*(.+)', fm, re.MULTILINE)
|
|
if desc_inline:
|
|
desc = desc_inline.group(1).strip()
|
|
|
|
dir_name = os.path.basename(skill_dir)
|
|
|
|
# --- Checks ---
|
|
|
|
# name present
|
|
if name:
|
|
ok(f"name present: '{name}'")
|
|
else:
|
|
fail("name field is missing or empty")
|
|
|
|
# name matches directory
|
|
if name and dir_name:
|
|
if name == dir_name:
|
|
ok(f"name '{name}' matches directory '{dir_name}'")
|
|
else:
|
|
fail(f"name '{name}' does not match directory '{dir_name}'")
|
|
|
|
# name length
|
|
if name:
|
|
if len(name) <= 64:
|
|
ok(f"name length {len(name)} chars (limit: 64)")
|
|
else:
|
|
fail(f"name '{name}' is {len(name)} chars — exceeds 64-character limit")
|
|
|
|
# name format
|
|
if name:
|
|
if re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name):
|
|
ok(f"name format valid (kebab-case)")
|
|
else:
|
|
fail(f"name '{name}' is invalid — use lowercase letters, numbers, and hyphens only; no leading, trailing, or consecutive hyphens")
|
|
|
|
# description present
|
|
if desc:
|
|
ok(f"description present")
|
|
else:
|
|
fail("description field is missing or empty")
|
|
|
|
# description length
|
|
if desc:
|
|
dlen = len(desc)
|
|
if dlen <= 1024:
|
|
ok(f"description length {dlen} chars (limit: 1024)")
|
|
else:
|
|
fail(f"description length {dlen} chars — exceeds 1024-character limit")
|
|
|
|
# Unfilled placeholder detection — matches FILL IN: followed by actual content,
|
|
# but not backtick-quoted references like `FILL IN:` used in instructions.
|
|
PLACEHOLDER_RE = re.compile(r'(?<!`)FILL IN:[^`\n]')
|
|
|
|
# description contains unfilled placeholder
|
|
if desc and PLACEHOLDER_RE.search(desc):
|
|
fail("description still contains 'FILL IN:' placeholder — replace before shipping")
|
|
else:
|
|
if desc:
|
|
ok("description has no unfilled placeholders")
|
|
|
|
# SKILL.md size ceilings (agentskills.io skill-authoring.md: 500 lines,
|
|
# ~5,000 tokens). Both constants are DUPLICATED from the repo-root pre-commit
|
|
# hook scripts/skill-size-check.sh — a plugin skill's scripts cannot read files
|
|
# outside the plugin directory once the plugin is cache-installed, so there is
|
|
# no single source to share. Keep the two in sync by hand: if they drift, this
|
|
# audit will report a skill ready to ship that the commit hook then rejects.
|
|
MAX_LINES = 500
|
|
# Word-count proxy for the ~5,000-token ceiling, calibrated to the densest
|
|
# prose in the corpus (7.22 chars/word): 2770 words is ~20,000 characters,
|
|
# ~5,000 tokens at 4 characters per token. See skill-size-check.sh's header
|
|
# for the full measurement.
|
|
MAX_WORDS = 2770
|
|
|
|
line_count = len(content.splitlines())
|
|
if line_count <= MAX_LINES:
|
|
ok(f"SKILL.md line count {line_count} (limit: {MAX_LINES})")
|
|
else:
|
|
fail(f"SKILL.md line count {line_count} — exceeds {MAX_LINES}-line limit")
|
|
|
|
# str.split() with no argument splits on runs of whitespace, matching the
|
|
# `wc -w` the hook uses, and counts the whole file including frontmatter.
|
|
word_count = len(content.split())
|
|
if word_count <= MAX_WORDS:
|
|
ok(f"SKILL.md word count {word_count} (limit: {MAX_WORDS}, proxy for ~5,000 tokens)")
|
|
else:
|
|
fail(f"SKILL.md word count {word_count} — exceeds {MAX_WORDS}-word limit (proxy for ~5,000 tokens)")
|
|
|
|
# Body unfilled placeholders
|
|
body = content[body_start:]
|
|
fill_matches = PLACEHOLDER_RE.findall(body)
|
|
if fill_matches:
|
|
fail(f"SKILL.md body contains {len(fill_matches)} unfilled 'FILL IN:' placeholder(s)")
|
|
else:
|
|
ok("SKILL.md body has no unfilled placeholders")
|
|
|
|
# Scripts checks
|
|
scripts_dir = os.path.join(skill_dir, "scripts")
|
|
if os.path.isdir(scripts_dir):
|
|
scripts = [f for f in os.listdir(scripts_dir)
|
|
if os.path.isfile(os.path.join(scripts_dir, f)) and not f.endswith('.md')]
|
|
for fname in scripts:
|
|
fpath = os.path.join(scripts_dir, fname)
|
|
with open(fpath) as f:
|
|
sc = f.read()
|
|
# Interactive prompt heuristic
|
|
if re.search(r'^\s*(read\s|input\()', sc, re.MULTILINE):
|
|
fail(f"scripts/{fname}: may use interactive input (read/input detected)")
|
|
else:
|
|
ok(f"scripts/{fname}: no interactive prompts detected")
|
|
# Executable bit
|
|
if os.access(fpath, os.X_OK):
|
|
ok(f"scripts/{fname}: is executable")
|
|
else:
|
|
fail(f"scripts/{fname}: not executable — run: chmod +x {fpath}")
|
|
|
|
# Summary
|
|
print()
|
|
if not failed:
|
|
print("All checks passed.")
|
|
sys.exit(0)
|
|
else:
|
|
print("One or more checks failed.")
|
|
sys.exit(1)
|
|
PYTHON
|