feat(kyberforge): execute plugin-to-apm marketplace conversion
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
This commit is contained in:
@@ -1,47 +0,0 @@
|
||||
# scripts/
|
||||
|
||||
Executable code bundled with this skill. Agents run scripts in this directory
|
||||
to perform repeatable operations rather than reinventing the logic each run.
|
||||
|
||||
## When to add a script
|
||||
|
||||
Add a script when agents independently reinvent the same logic across runs —
|
||||
building the same parser, chart, or validation routine from scratch each time.
|
||||
Bundle it here once, tested and reliable.
|
||||
|
||||
## Script requirements (agentskills.io)
|
||||
|
||||
Scripts must be designed for non-interactive, agentic execution:
|
||||
|
||||
- **No interactive prompts** — agents run in non-interactive shells.
|
||||
Accept all input via flags, env vars, or stdin. A script that blocks on
|
||||
TTY input hangs indefinitely.
|
||||
- **Expose `--help`** — this is how agents learn your script's interface.
|
||||
Keep the output concise; it enters the agent's context window.
|
||||
- **Structured output** — write data (JSON, CSV, TSV) to stdout.
|
||||
Write progress, warnings, and diagnostics to stderr.
|
||||
- **Idempotent** — prefer "create if not exists" over "create and fail on
|
||||
duplicate". Agents may retry on failure.
|
||||
- **Meaningful exit codes** — `0` for success, non-zero for failure.
|
||||
Use distinct codes for different failure types; document them in `--help`.
|
||||
- **Dry-run support** — add `--dry-run` for destructive operations.
|
||||
|
||||
## Self-contained scripts
|
||||
|
||||
Bundle dependencies inline so the agent can run the script with a single command.
|
||||
|
||||
Python (PEP 723 + uv):
|
||||
```python
|
||||
# /// script
|
||||
# dependencies = ["requests>=2.31,<3"]
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
import requests
|
||||
```
|
||||
```bash
|
||||
uv run scripts/my-script.py
|
||||
```
|
||||
|
||||
## If no scripts are needed
|
||||
|
||||
Delete this README and the `scripts/` directory entirely.
|
||||
@@ -1,526 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Works around a Vale limitation: the `text.frontmatter.description` NLP scope
|
||||
# silently stops matching once the `description:` value spans 2+ physical lines
|
||||
# in any form YAML joins back into one string — a `>`/`>-`/`>+` folded block
|
||||
# scalar (the style used by most skills/agents in this repo), a plain scalar
|
||||
# wrapped onto continuation lines, or a double- or single-quoted scalar wrapped
|
||||
# the same way. A `|`/`|-`/`|+` literal block scalar is NOT affected: its parsed
|
||||
# value keeps exactly the line breaks the source has, and vale matches it fine
|
||||
# (verified against vale 3.15.2), so literal blocks are deliberately left alone.
|
||||
# This script flattens an affected description to a one-line scalar in a scratch
|
||||
# copy — or, for the rare value no inline scalar can spell out verbatim, to a
|
||||
# `|-` literal block with a single content line, which vale matches just as well
|
||||
# (padding with blank lines so every other line number is unchanged), then
|
||||
# runs the real `vale` binary against the copies. Drop-in replacement for calling
|
||||
# `vale` directly: same args, same exit code, bar the two documented divergences
|
||||
# below.
|
||||
#
|
||||
# "Same args" means relative paths — path arguments and the values of the
|
||||
# path-valued flags (`--config`, `--output`, `--path`) alike — resolve against
|
||||
# the caller's current directory, exactly as bare `vale` resolves them. The flag
|
||||
# values are rewritten to absolute form because the run ends up `cd`'d into the
|
||||
# scratch mirror, where a relative one would no longer resolve. (An earlier
|
||||
# version resolved path arguments against the repo root, an invented convention
|
||||
# that hard-errored on `--config ../../.vale.ini` from a subdirectory and, worse,
|
||||
# silently dropped file arguments that didn't happen to resolve from the repo
|
||||
# root — skipping the flattening this script exists for.)
|
||||
#
|
||||
# Divergence 1: with no `--config` at all, this script's own sibling
|
||||
# `assets/vale/.vale.ini` is used instead of vale's upward search. pre-commit
|
||||
# prefixes only `entry[0]` with the hook-repo clone path, so a `--config` in
|
||||
# `.pre-commit-hooks.yaml` would resolve against the *consuming* repo and
|
||||
# hard-fail (E100) for every external consumer. The manifest therefore passes the
|
||||
# script alone, and an explicit `--config` from any other caller still wins.
|
||||
#
|
||||
# Divergence 2: a path-shaped argument that does not exist is a hard error
|
||||
# (exit 2). Bare vale drops it, falls back to reading stdin, and prints
|
||||
# `0 errors ... in stdin` with exit 0 — a typo'd target is then indistinguishable
|
||||
# from a clean run. Both audit skills treat a `0 files` report as NOT RUN rather
|
||||
# than clean, and `in stdin` does not match that guard, so the silent form would
|
||||
# read as "prefilter clean" and skip the LLM fallback. Erroring is the only way
|
||||
# to keep that guard honest. Linting prose piped on stdin is therefore
|
||||
# unsupported here — it already was, since the no-path handoff closes stdin so
|
||||
# vale can't block on a pipe that will never carry content.
|
||||
#
|
||||
# Vale prints each path exactly as it was handed to it, so the scratch tree
|
||||
# mirrors the caller's absolute cwd: a relative path argument is passed through
|
||||
# verbatim and resolves to its flattened copy, keeping the report byte-identical
|
||||
# to bare `vale`'s. An absolute path inside the cwd is relativized to keep that
|
||||
# property. Only an absolute path outside the cwd is rewritten to its scratch
|
||||
# copy and so reports a scratch path — unavoidable, since a file can only be
|
||||
# read from where it actually is.
|
||||
|
||||
cwd="$(pwd -P)"
|
||||
|
||||
# Every array below is expanded as `${arr[@]+"${arr[@]}"}`: bash before 4.4 —
|
||||
# including the 3.2 that macOS still ships as /bin/bash — treats `"${arr[@]}"`
|
||||
# on an empty array as an unbound variable under `set -u`. No expansion site is
|
||||
# reachable while empty on today's control flow, so this is insurance against a
|
||||
# later edit breaking that invariant, not a live fix.
|
||||
vale_args=()
|
||||
path_args=()
|
||||
pending_flag=""
|
||||
config_given=false
|
||||
|
||||
# `--output` takes either one of vale's built-in style names or a template file
|
||||
# path. Only the file form needs absolutizing, and the built-in names have to be
|
||||
# excluded by name *before* the existence test below: a file or directory
|
||||
# literally called `line` in the caller's cwd would otherwise rewrite the
|
||||
# built-in into `$cwd/line`, flipping vale into template mode (`E100 [template]
|
||||
# Runtime error`) where bare vale just uses the built-in. `--path` has no such
|
||||
# names — it is always a path — so the check is keyed on the flag too.
|
||||
is_builtin_output() {
|
||||
case "$2" in
|
||||
line|JSON|CLI) [[ "$1" == "--output" ]] ;;
|
||||
*) false ;;
|
||||
esac
|
||||
}
|
||||
# Absolutizes a `--config` value against the caller's cwd. Shared by both
|
||||
# argument forms below — separated (`--config X`) and joined (`--config=X`)
|
||||
# — so the "already absolute vs. needs $cwd prefixed" check lives in exactly
|
||||
# one place instead of being duplicated per form.
|
||||
abs_config_value() {
|
||||
if [[ "$1" == /* ]]; then
|
||||
printf '%s' "$1"
|
||||
else
|
||||
printf '%s' "$cwd/$1"
|
||||
fi
|
||||
}
|
||||
for arg in "$@"; do
|
||||
if [[ -n "$pending_flag" ]]; then
|
||||
# Value of a separated two-argv flag. It is never a lint target, however
|
||||
# file-like it looks. The run ends up `cd`'d into the scratch mirror, so a
|
||||
# value naming a file has to be absolutized here or it stops resolving.
|
||||
case "$pending_flag" in
|
||||
--config)
|
||||
# Always a path, and required to exist.
|
||||
vale_args+=("$(abs_config_value "$arg")")
|
||||
;;
|
||||
--output|--path)
|
||||
# See `is_builtin_output` above for why the built-in `--output` names
|
||||
# are excluded first. Anything that names nothing is passed through and
|
||||
# left for vale to interpret.
|
||||
if is_builtin_output "$pending_flag" "$arg"; then
|
||||
vale_args+=("$arg")
|
||||
elif [[ "$arg" != /* && -e "$arg" ]]; then
|
||||
vale_args+=("$cwd/$arg")
|
||||
else
|
||||
vale_args+=("$arg")
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
vale_args+=("$arg")
|
||||
;;
|
||||
esac
|
||||
pending_flag=""
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--config)
|
||||
vale_args+=("$arg")
|
||||
pending_flag="$arg"
|
||||
config_given=true
|
||||
continue
|
||||
;;
|
||||
--config=*)
|
||||
vale_args+=("--config=$(abs_config_value "${arg#--config=}")")
|
||||
config_given=true
|
||||
continue
|
||||
;;
|
||||
# Same cwd-relative resolution for the `--flag=value` spelling of the two
|
||||
# other path-valued flags.
|
||||
--output=*|--path=*)
|
||||
flag_val="${arg#*=}"
|
||||
if is_builtin_output "${arg%%=*}" "$flag_val"; then
|
||||
vale_args+=("$arg")
|
||||
elif [[ "$flag_val" != /* && -n "$flag_val" && -e "$flag_val" ]]; then
|
||||
vale_args+=("${arg%%=*}=$cwd/$flag_val")
|
||||
else
|
||||
vale_args+=("$arg")
|
||||
fi
|
||||
continue
|
||||
;;
|
||||
# Vale's remaining value-taking flags, per `vale --help` (3.x). In the
|
||||
# separated two-argv form the value must not be classified as a lint target
|
||||
# — `--output tmpl.tmpl` names a real template file, and treating it as
|
||||
# input both lints the template and reorders argv so vale sees
|
||||
# `--output --no-wrap`. The `--flag=value` form needs no entry here: it
|
||||
# starts with `-` and falls through to vale untouched. A value flag added by
|
||||
# some future vale release is simply absent from this list and lands back on
|
||||
# today's behaviour, so this list going stale is never worse than not having
|
||||
# it.
|
||||
--ext|--filter|--glob|--minAlertLevel|--output|--path)
|
||||
vale_args+=("$arg")
|
||||
pending_flag="$arg"
|
||||
continue
|
||||
;;
|
||||
# Vale's subcommands are bare words that name no file, so they would trip
|
||||
# the not-found error below. A lint target literally named `sync` (no
|
||||
# extension, no slash) is misread as the subcommand — accepted, because the
|
||||
# alternative is failing every `vale-wrap.sh ls-config`.
|
||||
ls-config|ls-dirs|ls-metrics|ls-vars|sync)
|
||||
vale_args+=("$arg")
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
if [[ "$arg" == -* ]]; then
|
||||
vale_args+=("$arg")
|
||||
continue
|
||||
fi
|
||||
# Everything left is a lint target: `vale [options] [input...]` has no third
|
||||
# kind of argument. See divergence 2 above for why a missing one is fatal here.
|
||||
if [[ ! -e "$arg" ]]; then
|
||||
echo "vale-wrap.sh: no such file or directory: $arg" >&2
|
||||
exit 2
|
||||
fi
|
||||
# An absolute path inside the caller's cwd is relativized so the report cites
|
||||
# a path that resolves against the real tree. Left absolute, it would be
|
||||
# rewritten to its scratch copy and printed as `/tmp/tmp.XXXX/...` — a real
|
||||
# path to a file that is deleted on exit, which reads as a bug in any report
|
||||
# quoting it. Absolute paths outside the cwd have no relative form and keep
|
||||
# the scratch-path behaviour documented above.
|
||||
if [[ "$arg" == "$cwd"/* ]]; then
|
||||
path_args+=("${arg#"$cwd"/}")
|
||||
else
|
||||
path_args+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$config_given" == false ]]; then
|
||||
vale_args+=(--config "$(cd "$(dirname "${BASH_SOURCE[0]}")/../assets/vale" && pwd)/.vale.ini")
|
||||
fi
|
||||
|
||||
if [[ ${#path_args[@]} -eq 0 ]]; then
|
||||
# Nothing to flatten. Hand off directly, with stdin closed so vale doesn't
|
||||
# block waiting on a pipe that will never carry content.
|
||||
exec vale ${vale_args[@]+"${vale_args[@]}"} < /dev/null
|
||||
fi
|
||||
|
||||
# `realpath -m` would be the obvious normalizer, but `-m` (canonicalize-missing)
|
||||
# is a GNU extension the BSD realpath on macOS doesn't have — and every dest
|
||||
# below is a path that doesn't exist yet. python3 is already a hard dependency.
|
||||
abspath() {
|
||||
python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "$1"
|
||||
}
|
||||
|
||||
flatten() {
|
||||
# Two call shapes: `flatten src dest` (dest already resolved and inside the
|
||||
# scratch tree — the per-markdown-file calls in the directory branch below)
|
||||
# writes straight to `dest`. `flatten src raw_dest tmpdir` (the single-file
|
||||
# branch further down) additionally resolves `raw_dest` the way a separate
|
||||
# `abspath` call used to, applies the same sandbox-escape guard, and prints
|
||||
# the resolved path — folding two python3 spawns per file into one.
|
||||
python3 - "$@" <<'PYTHON'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
src, dest_input = sys.argv[1], sys.argv[2]
|
||||
tmpdir = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
|
||||
if tmpdir is None:
|
||||
dest = dest_input
|
||||
else:
|
||||
dest = os.path.abspath(dest_input)
|
||||
if not dest.startswith(tmpdir + os.sep):
|
||||
print(
|
||||
f"vale-wrap.sh: refusing to lint '{src}': its scratch copy would "
|
||||
f"land outside {tmpdir}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
|
||||
# surrogateescape keeps a non-UTF-8 file (reachable via a directory argument)
|
||||
# a byte-for-byte round trip instead of aborting the whole run on a decode error.
|
||||
with open(src, encoding='utf-8', errors='surrogateescape') as fh:
|
||||
content = fh.read()
|
||||
|
||||
# YAML 1.2 double-quoted escapes (spec 5.7 / 7.3.1). `\<newline>` is handled
|
||||
# separately in unescape_double because it also swallows the next indentation.
|
||||
DQ_ESCAPES = {
|
||||
'0': '\0', 'a': '\a', 'b': '\b', 't': '\t', '\t': '\t', 'n': '\n',
|
||||
'v': '\v', 'f': '\f', 'r': '\r', 'e': '\x1b', ' ': ' ', '"': '"',
|
||||
'/': '/', '\\': '\\', 'N': '\x85', '_': '\xa0', 'L': '\u2028',
|
||||
'P': '\u2029',
|
||||
}
|
||||
|
||||
# First characters that make a plain (unquoted) scalar mean something other than
|
||||
# text: YAML's c-indicator set.
|
||||
PLAIN_UNSAFE_FIRST = '-?:,[]{}#&*!|>\'"%@`'
|
||||
|
||||
|
||||
def unescape_double(text):
|
||||
"""Decode a double-quoted YAML scalar's body to the string YAML parses."""
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
char = text[i]
|
||||
if char != '\\':
|
||||
out.append(char)
|
||||
i += 1
|
||||
continue
|
||||
i += 1
|
||||
if i >= len(text):
|
||||
break
|
||||
esc = text[i]
|
||||
if esc == '\n':
|
||||
i += 1
|
||||
while i < len(text) and text[i] in ' \t':
|
||||
i += 1
|
||||
continue
|
||||
if esc in 'xuU':
|
||||
width = {'x': 2, 'u': 4, 'U': 8}[esc]
|
||||
digits = text[i + 1:i + 1 + width]
|
||||
if len(digits) == width:
|
||||
try:
|
||||
out.append(chr(int(digits, 16)))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
i += 1 + width
|
||||
continue
|
||||
out.append(DQ_ESCAPES.get(esc, esc))
|
||||
i += 1
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def close_quote(text, quote):
|
||||
"""Index of the closing `quote` in `text`, which starts just past the
|
||||
opening one. None while the scalar is still unterminated."""
|
||||
i = 0
|
||||
while i < len(text):
|
||||
char = text[i]
|
||||
if quote == '"' and char == '\\':
|
||||
i += 2
|
||||
continue
|
||||
if char == quote:
|
||||
if quote == "'" and text[i + 1:i + 2] == "'":
|
||||
i += 2
|
||||
continue
|
||||
return i
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def continuation_lines(rest):
|
||||
"""Yield the physical lines of `rest` that continue the value started on the
|
||||
`description:` line. Indentation-based and blank-line-tolerant, per YAML:
|
||||
a blank line (any amount of whitespace) always stays inside; the indent is
|
||||
set by the first content line; the value ends at the first line indented
|
||||
less than that, at any line flush with the key (that is the next mapping
|
||||
key, not a continuation), or at EOF."""
|
||||
indent = None
|
||||
for line in rest.splitlines(keepends=True):
|
||||
text = line.rstrip('\n')
|
||||
if text.strip() == '':
|
||||
yield line
|
||||
continue
|
||||
line_indent = len(text) - len(text.lstrip(' \t'))
|
||||
if line_indent == 0:
|
||||
return
|
||||
if indent is None:
|
||||
indent = line_indent
|
||||
elif line_indent < indent:
|
||||
return
|
||||
yield line
|
||||
|
||||
|
||||
def emit(value):
|
||||
"""Render `value` as a YAML scalar whose source text spells the value out
|
||||
verbatim. Vale locates the description by matching the parsed value back
|
||||
against the source, so a scalar carrying any escape — `''` in a
|
||||
single-quoted scalar, `\\"` or `\\\\` in a double-quoted one — makes the
|
||||
whole `text.frontmatter.description` scope vanish, the same failure this
|
||||
script exists to work around. Verbatim forms only, therefore, tried in
|
||||
descending order of fidelity. The first three occupy one physical line; the
|
||||
`|-` fallback occupies two, which the caller accounts for when padding."""
|
||||
if (value
|
||||
and value[0] not in PLAIN_UNSAFE_FIRST
|
||||
and ': ' not in value
|
||||
and not value.endswith(':')
|
||||
and ' #' not in value):
|
||||
return value # plain: nothing needs escaping at all
|
||||
if "'" not in value:
|
||||
return "'" + value + "'" # single-quoted: only `'` would escape
|
||||
if '"' not in value and '\\' not in value:
|
||||
return '"' + value + '"' # double-quoted: only `"`/`\` would
|
||||
# Last resort: the value needs quoting AND holds an apostrophe AND a double
|
||||
# quote or backslash, so no *inline* scalar can carry it verbatim. A `|-`
|
||||
# literal block can — a block scalar's body has no escape syntax at all, so
|
||||
# `'`, `"`, `\` and `: ` all survive byte for byte, and vale still matches
|
||||
# the description scope against it (the header above says the same of the
|
||||
# `|` blocks this script deliberately leaves alone; verified against vale
|
||||
# 3.15.2). One content line, indented two spaces, `-`-chomped so the parsed
|
||||
# value is exactly `value` with no trailing newline.
|
||||
return '|-\n ' + value
|
||||
|
||||
|
||||
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
|
||||
if fm_match:
|
||||
fm = fm_match.group(2)
|
||||
header_m = re.search(r'^description:[ \t]*', fm, re.MULTILINE)
|
||||
else:
|
||||
header_m = None
|
||||
|
||||
if header_m:
|
||||
head_start = header_m.start()
|
||||
value_start = header_m.end()
|
||||
header_end = fm.find('\n', value_start)
|
||||
header_end = len(fm) if header_end == -1 else header_end
|
||||
first = fm[value_start:header_end]
|
||||
body_start = header_end + 1
|
||||
indicator = first.rstrip()
|
||||
|
||||
block_m = re.fullmatch(r'([|>])([+-]?[0-9]*|[0-9]*[+-]?)', indicator)
|
||||
if block_m and block_m.group(1) == '|':
|
||||
kind = None # literal blocks keep their line breaks; vale is fine
|
||||
elif block_m:
|
||||
kind = 'block' # folded (`>`): the value starts on the next line
|
||||
elif indicator == '':
|
||||
kind = 'block' # bare `description:`: a plain scalar on later lines
|
||||
elif first[:1] == '"':
|
||||
kind = 'double'
|
||||
elif first[:1] == "'":
|
||||
kind = 'single'
|
||||
elif first[:1] in '#&*!':
|
||||
kind = None # comment, anchor, alias or tag — not a plain scalar
|
||||
else:
|
||||
kind = 'plain'
|
||||
|
||||
text = ''
|
||||
value_end = value_start
|
||||
value_lines = 0
|
||||
if kind in ('block', 'plain'):
|
||||
body = ''.join(continuation_lines(fm[body_start:]))
|
||||
value_end = body_start + len(body)
|
||||
if kind == 'block':
|
||||
text = body
|
||||
value_lines = body.count('\n')
|
||||
else:
|
||||
text = fm[value_start:value_end]
|
||||
value_lines = 1 + body.count('\n')
|
||||
if ' #' in text or text.lstrip().startswith('#'):
|
||||
# A `#` opens a comment inside a plain scalar. Folding it in
|
||||
# would lint text YAML never treats as part of the value, so
|
||||
# leave the file alone rather than lint the wrong string.
|
||||
kind = None
|
||||
elif kind in ('double', 'single'):
|
||||
quote = '"' if kind == 'double' else "'"
|
||||
inner_start = value_start + 1
|
||||
acc = fm[inner_start:body_start]
|
||||
idx = close_quote(acc, quote)
|
||||
lines = continuation_lines(fm[body_start:])
|
||||
while idx is None:
|
||||
try:
|
||||
acc += next(lines)
|
||||
except StopIteration:
|
||||
break
|
||||
idx = close_quote(acc, quote)
|
||||
if idx is None:
|
||||
kind = None # unterminated quote: invalid YAML, leave it to vale
|
||||
else:
|
||||
inner = acc[:idx]
|
||||
value_end = inner_start + idx + 1
|
||||
text = unescape_double(inner) if quote == '"' else inner.replace("''", "'")
|
||||
value_lines = 1 + inner.count('\n')
|
||||
|
||||
flat = re.sub(r'\s+', ' ', text).strip()
|
||||
if kind and flat and value_lines >= 2:
|
||||
# `value_end` can land mid-line, just past a closing quote, so extend to
|
||||
# the end of that physical line and carry whatever follows (a trailing
|
||||
# comment) across unchanged.
|
||||
if value_end > 0 and fm[value_end - 1] == '\n':
|
||||
span_end = value_end
|
||||
trailer = ''
|
||||
else:
|
||||
newline = fm.find('\n', value_end)
|
||||
span_end = len(fm) if newline == -1 else newline + 1
|
||||
trailer = fm[value_end:span_end].rstrip('\n')
|
||||
scalar = emit(flat)
|
||||
# A trailing comment carried across from the original line stays on the
|
||||
# `description:` line itself: after a block scalar's `|-` header it is
|
||||
# still a comment, but inside the block body it would become part of the
|
||||
# value.
|
||||
head, newline_sep, block_body = scalar.partition('\n')
|
||||
# The replacement displaces the whole span, so the blank-line pad makes
|
||||
# up the difference between the lines it displaced and the lines it
|
||||
# occupies — every later line number is unchanged. That is one line for
|
||||
# the three inline forms and two for the `|-` block; the span itself is
|
||||
# at least two lines here (`value_lines >= 2` is a precondition), so the
|
||||
# pad count never goes negative.
|
||||
pad = '\n' * (fm[head_start:span_end].count('\n') - 1 - scalar.count('\n'))
|
||||
new_fm = (fm[:head_start] + 'description: ' + head + trailer
|
||||
+ newline_sep + block_body + '\n' + pad + fm[span_end:])
|
||||
content = (fm_match.group(1) + new_fm + fm_match.group(3)
|
||||
+ content[fm_match.end():])
|
||||
|
||||
with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh:
|
||||
fh.write(content)
|
||||
|
||||
if tmpdir is not None:
|
||||
print(dest)
|
||||
PYTHON
|
||||
}
|
||||
|
||||
tmpdir="$(cd "$(mktemp -d)" && pwd -P)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
# Mirror of the caller's cwd inside the scratch tree; relative path arguments
|
||||
# are resolved from here.
|
||||
mirror="$tmpdir$cwd"
|
||||
mkdir -p "$mirror"
|
||||
|
||||
argv_paths=()
|
||||
for arg in ${path_args[@]+"${path_args[@]}"}; do
|
||||
if [[ "$arg" == /* ]]; then
|
||||
raw_dest="$tmpdir$arg"
|
||||
else
|
||||
raw_dest="$mirror/$arg"
|
||||
fi
|
||||
if [[ -d "$arg" ]]; then
|
||||
dest="$(abspath "$raw_dest")"
|
||||
# A path argument with enough leading `..` to climb past the mirror root would
|
||||
# write outside the scratch dir. The real filesystem clamps such a path at
|
||||
# `/`; the mirror can't, so refuse rather than scribble outside the sandbox.
|
||||
case "$dest" in
|
||||
"$tmpdir"/*) ;;
|
||||
*)
|
||||
echo "vale-wrap.sh: refusing to lint '$arg': its scratch copy would land outside $tmpdir" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
# A directory is mirrored whole — vale applies its own format filtering to
|
||||
# the tree, so any file dropped here would be silently unlinted — and then
|
||||
# every markdown file in the copy is flattened in place. `.git` is pruned:
|
||||
# vale never lints it and copying it can dwarf the rest of the tree.
|
||||
# `find -L` follows symlinks because vale does: it lints both a symlinked
|
||||
# file and a file under a symlinked directory, and a bare `-type f` walk
|
||||
# would report "0 files" where bare vale reports one. (A symlink loop makes
|
||||
# `find` warn on stderr and carry on, which is also what vale does.) The
|
||||
# second walk needs no `-L`: the mirror is all real files by construction.
|
||||
mkdir -p "$dest"
|
||||
while IFS= read -r -d '' rel; do
|
||||
mkdir -p "$dest/$(dirname "$rel")"
|
||||
cp "$arg/$rel" "$dest/$rel"
|
||||
done < <(cd "$arg" && find -L . -name .git -prune -o -type f -print0)
|
||||
while IFS= read -r -d '' md; do
|
||||
flatten "$md" "$md"
|
||||
done < <(find "$dest" -type f -name '*.md' -print0)
|
||||
else
|
||||
# `abspath` + `flatten` folded into one python3 process — see the comment
|
||||
# atop `flatten` above.
|
||||
dest="$(flatten "$arg" "$raw_dest" "$tmpdir")"
|
||||
fi
|
||||
if [[ "$arg" == /* ]]; then
|
||||
argv_paths+=("$dest")
|
||||
else
|
||||
argv_paths+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$mirror"
|
||||
vale ${vale_args[@]+"${vale_args[@]}"} ${argv_paths[@]+"${argv_paths[@]}"}
|
||||
@@ -1,290 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: validate-provenance.sh <agent-file>
|
||||
|
||||
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/<name>.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'(?<!`)FILL IN:[^`\n]')
|
||||
|
||||
def parse_frontmatter(content):
|
||||
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
if not m:
|
||||
return None, content
|
||||
return m.group(1), content[m.end():]
|
||||
|
||||
def parse_source_keys(fm):
|
||||
"""Extract top-level source_keys list from frontmatter string."""
|
||||
if fm is None:
|
||||
return []
|
||||
keys = []
|
||||
in_source_keys = False
|
||||
for line in fm.splitlines():
|
||||
if re.match(r'^source_keys:', line):
|
||||
in_source_keys = True
|
||||
continue
|
||||
if in_source_keys:
|
||||
m = re.match(r'^[ \t]+-\s+(\S+)', line)
|
||||
if m:
|
||||
keys.append(m.group(1).strip())
|
||||
elif line and not line[0].isspace():
|
||||
in_source_keys = False
|
||||
return keys
|
||||
|
||||
def parse_h2_slugs(content):
|
||||
return re.findall(r'^## (.+)$', content, re.MULTILINE)
|
||||
|
||||
def parse_contributing_files(content, slug):
|
||||
pattern = re.compile(
|
||||
r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)',
|
||||
re.MULTILINE | re.DOTALL
|
||||
)
|
||||
m = pattern.search(content)
|
||||
if not m:
|
||||
return None
|
||||
block = m.group(1)
|
||||
cf_m = re.search(r'^\- \*\*Contributing files:\*\* (.+)$', block, re.MULTILINE)
|
||||
if not cf_m:
|
||||
return None
|
||||
return cf_m.group(1).strip()
|
||||
|
||||
def parse_research_doc(content, slug):
|
||||
pattern = re.compile(
|
||||
r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)',
|
||||
re.MULTILINE | re.DOTALL
|
||||
)
|
||||
m = pattern.search(content)
|
||||
if not m:
|
||||
return None
|
||||
block = m.group(1)
|
||||
rd_m = re.search(r'^\- \*\*Research doc:\*\* (.+)$', block, re.MULTILINE)
|
||||
if not rd_m:
|
||||
return None
|
||||
return rd_m.group(1).strip()
|
||||
|
||||
findings = []
|
||||
has_fail = False
|
||||
|
||||
def emit_fail(desc, fpath, why, fix):
|
||||
global has_fail
|
||||
has_fail = True
|
||||
findings.append(("FAIL", desc, fpath, why, fix))
|
||||
|
||||
def print_findings():
|
||||
for kind, desc, fpath, why, fix in findings:
|
||||
print(f"FAIL {desc} — {fpath}")
|
||||
print(f" Why: {why}")
|
||||
print(f" Fix: {fix}")
|
||||
print()
|
||||
|
||||
# --- Collect source_keys from agent pair ---
|
||||
def get_source_keys_from_file(fpath):
|
||||
if not os.path.isfile(fpath):
|
||||
return []
|
||||
try:
|
||||
with open(fpath) as f:
|
||||
content = f.read()
|
||||
except Exception:
|
||||
return []
|
||||
fm, _ = parse_frontmatter(content)
|
||||
return parse_source_keys(fm)
|
||||
|
||||
# Plugin/APM scope is a single vendor-neutral file — no counterpart to merge.
|
||||
given_keys = get_source_keys_from_file(agent_file)
|
||||
all_source_keys = given_keys
|
||||
|
||||
sources_md_exists = os.path.isfile(sources_md_path)
|
||||
|
||||
# Early exit: nothing to validate
|
||||
if not all_source_keys and not sources_md_exists:
|
||||
sys.exit(0)
|
||||
|
||||
sources_content = None
|
||||
sources_slugs = set()
|
||||
if sources_md_exists:
|
||||
with open(sources_md_path) as f:
|
||||
sources_content = f.read()
|
||||
sources_slugs = set(parse_h2_slugs(sources_content))
|
||||
|
||||
# --- Check 0: source_keys present but sources.md absent ---
|
||||
if not sources_md_exists and all_source_keys:
|
||||
rel_given = os.path.relpath(agent_file, plugin_root)
|
||||
emit_fail(
|
||||
"source_keys declared but sources.md is absent",
|
||||
rel_given,
|
||||
"source_keys references research provenance that has no sources index to validate against.",
|
||||
"Create sources.md with an H2 entry for each slug referenced by source_keys."
|
||||
)
|
||||
print_findings()
|
||||
sys.exit(1)
|
||||
|
||||
# --- Check 1: FILL IN: placeholders in sources.md ---
|
||||
for line in sources_content.splitlines():
|
||||
if PLACEHOLDER_RE.search(line):
|
||||
emit_fail(
|
||||
"Unfilled FILL IN: placeholder",
|
||||
"sources.md",
|
||||
"sources.md contains an unfilled placeholder, meaning provenance is incomplete.",
|
||||
"Replace all 'FILL IN:' values in sources.md with real content."
|
||||
)
|
||||
break
|
||||
|
||||
# --- Check 2: source_keys in the agent file → slug exists in sources.md ---
|
||||
for fpath, keys in [(agent_file, given_keys)]:
|
||||
if not keys:
|
||||
continue
|
||||
rel = os.path.relpath(fpath, plugin_root)
|
||||
for slug in keys:
|
||||
if slug not in sources_slugs:
|
||||
emit_fail(
|
||||
f"source_keys slug '{slug}' not found in sources.md",
|
||||
rel,
|
||||
f"'{rel}' declares '{slug}' as a source but there is no '## {slug}' heading in sources.md.",
|
||||
f"Add '## {slug}' entry to sources.md or remove '{slug}' from {rel} source_keys."
|
||||
)
|
||||
|
||||
# --- Checks 3, 4, 5: Per-slug checks in sources.md ---
|
||||
for slug in parse_h2_slugs(sources_content):
|
||||
# Check 3: Contributing files exist (paths relative to plugin root)
|
||||
cf_value = parse_contributing_files(sources_content, slug)
|
||||
if cf_value and not cf_value.startswith("(none"):
|
||||
cf_files = [p.strip() for p in cf_value.split(",") if p.strip()]
|
||||
for cf_rel in cf_files:
|
||||
cf_abs = os.path.join(plugin_root, cf_rel)
|
||||
if not os.path.isfile(cf_abs):
|
||||
emit_fail(
|
||||
f"Contributing file '{cf_rel}' does not exist",
|
||||
f"sources.md (## {slug})",
|
||||
f"sources.md claims '{cf_rel}' was contributed to by slug '{slug}' but the file does not exist.",
|
||||
f"Create '{cf_rel}' relative to the plugin root, or correct the path in sources.md."
|
||||
)
|
||||
else:
|
||||
# Check 4: Bidirectional — file should list slug in its source_keys
|
||||
with open(cf_abs) as f:
|
||||
cf_content = f.read()
|
||||
cf_fm, _ = parse_frontmatter(cf_content)
|
||||
cf_keys = parse_source_keys(cf_fm)
|
||||
if slug not in cf_keys:
|
||||
emit_fail(
|
||||
f"Contributing file '{cf_rel}' does not list '{slug}' in its source_keys",
|
||||
f"sources.md (## {slug})",
|
||||
f"sources.md says '{cf_rel}' was informed by '{slug}', but '{cf_rel}' does not declare '{slug}' in its top-level source_keys.",
|
||||
f"Add '{slug}' to the top-level source_keys frontmatter in '{cf_rel}'."
|
||||
)
|
||||
|
||||
# Check 5: Research doc field required
|
||||
rd_value = parse_research_doc(sources_content, slug)
|
||||
if rd_value is None:
|
||||
emit_fail(
|
||||
"Research doc field missing",
|
||||
f"sources.md (## {slug})",
|
||||
f"The '## {slug}' entry in sources.md has no '- **Research doc:**' line.",
|
||||
f"Add '- **Research doc:** <path-or-(none)>' 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
|
||||
@@ -1,388 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: validate.sh <agent-file>
|
||||
|
||||
Validate an agent definition file against the agent definition spec.
|
||||
|
||||
At plugin/APM scope, <agent-file> is a single vendor-neutral
|
||||
.apm/agents/<name>.agent.md file (frontmatter allowlist: name, description,
|
||||
model — no counterpart file). At project or user scope, <agent-file> 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'(?<!`)FILL IN:[^`\n]')
|
||||
|
||||
def parse_frontmatter(content):
|
||||
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
if not m:
|
||||
return None, content
|
||||
return m.group(1), content[m.end():]
|
||||
|
||||
def extract_field(fm, field):
|
||||
m = re.search(rf'^{re.escape(field)}:\s*(.+)', fm, re.MULTILINE)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
def get_frontmatter_keys(fm):
|
||||
keys = set()
|
||||
for line in fm.splitlines():
|
||||
m = re.match(r'^([a-zA-Z][a-zA-Z0-9_-]*):', line)
|
||||
if m:
|
||||
keys.add(m.group(1))
|
||||
return keys
|
||||
|
||||
def extract_tools_list(fm):
|
||||
"""Extract tool names from the tools frontmatter field (space or comma separated)."""
|
||||
val = extract_field(fm, 'tools')
|
||||
if not val:
|
||||
return set()
|
||||
return set(re.split(r'[\s,]+', val.strip()))
|
||||
|
||||
def is_copilot_cloud_ide(fpath):
|
||||
"""True if the file is a cloud/IDE Copilot agent (name is optional for these)."""
|
||||
return '.github/copilot/agents' in os.path.abspath(fpath).replace(os.sep, '/')
|
||||
|
||||
# --- Detect scope ---
|
||||
# APM_TYPE_RE matches a top-level (column-0) `type:` line in apm.yml whose value is
|
||||
# exactly one of the four package content types. Group 1 captures an optional
|
||||
# opening quote; \1 requires the same character (or nothing) to close it, so
|
||||
# "skill" and '"skill"' both match but a mismatched quote doesn't. The value
|
||||
# must then be followed by whitespace or end-of-line — not just a non-word
|
||||
# character — so a malformed value like `prompts-only` is correctly rejected
|
||||
# instead of false-matching on the `prompts` prefix.
|
||||
APM_TYPE_RE = re.compile(r"^type:\s*(['\"]?)(instructions|skill|hybrid|prompts)\1(?:\s|$)")
|
||||
|
||||
def find_apm_package_root(apm_yml_path):
|
||||
"""Return True if apm_yml_path has a top-level type: line (i.e. is a package
|
||||
manifest, not a type:-less marketplace-only apm.yml)."""
|
||||
with open(apm_yml_path) as f:
|
||||
for line in f:
|
||||
if APM_TYPE_RE.match(line):
|
||||
return True
|
||||
return False
|
||||
|
||||
def detect_scope(start_dir):
|
||||
home = os.path.expanduser('~')
|
||||
original_start = os.path.abspath(start_dir)
|
||||
# Agent files conventionally live exactly two path segments below their
|
||||
# scope root — <root>/.claude/agents, <root>/.github/agents,
|
||||
# <root>/.copilot/agents, or <root>/.apm/agents (see new-agent.sh's
|
||||
# CC_DIR/CP_DIR and user-scope dirs). Stripping those two segments
|
||||
# recovers the same root new-agent.sh would have been invoked with to
|
||||
# produce this exact file, independent of how far the walk below has to
|
||||
# travel to find (or fail to find) a marker — mirrors new-agent.sh's
|
||||
# `root` vs `current` distinction even though validate.sh is handed a
|
||||
# file's directory, not the scope root itself.
|
||||
#
|
||||
# That arithmetic is only trustworthy when the path actually has this
|
||||
# shape: parent directory literally named "agents", grandparent one of
|
||||
# the four known scope-dir names. A hand-placed or otherwise
|
||||
# non-conventional agent file (never produced by new-agent.sh) has no
|
||||
# such guarantee — blindly trusting two-segments-up there could point at
|
||||
# an unrelated ancestor. conventional_shape gates every use of
|
||||
# conventional_root below; when it's false, the walked-to `current`
|
||||
# directory is used instead, the same fallback this function used before
|
||||
# conventional_root existed.
|
||||
scope_dir_name = os.path.basename(os.path.dirname(original_start))
|
||||
conventional_shape = (
|
||||
os.path.basename(original_start) == 'agents'
|
||||
and scope_dir_name in ('.claude', '.github', '.copilot', '.apm')
|
||||
)
|
||||
conventional_root = os.path.dirname(os.path.dirname(original_start))
|
||||
current = original_start
|
||||
while True:
|
||||
apm_yml = os.path.join(current, 'apm.yml')
|
||||
if os.path.isfile(apm_yml) and find_apm_package_root(apm_yml):
|
||||
return 'plugin', current
|
||||
# $HOME is the user-scope boundary — checked before the .git test
|
||||
# below, so a dotfiles-managed $HOME (yadm, chezmoi bare-repo, etc.)
|
||||
# can't shadow user scope by being its own .git repo. 'user' scope
|
||||
# requires EITHER start_dir to BE $HOME itself (no walk-up — the
|
||||
# new-agent.sh "root exactly $HOME" case) OR start_dir to sit at the
|
||||
# conventional two-segments-below-root depth (i.e. $HOME IS that
|
||||
# root, matching the real ~/.claude/agents or ~/.copilot/agents
|
||||
# shape). Any other walk-up into $HOME — a marker-less directory
|
||||
# nested deeper than that convention — resolves to project scope
|
||||
# instead: a stray directory under $HOME can't be silently
|
||||
# redirected into the shared global ~/.claude or ~/.copilot agent
|
||||
# directories.
|
||||
if current == home:
|
||||
if original_start == home or (conventional_shape and conventional_root == home):
|
||||
return 'user', home
|
||||
return 'project', conventional_root if conventional_shape else current
|
||||
# .git is a directory in a normal checkout but a file (`gitdir: ...`)
|
||||
# in a git worktree — exists() covers both. Returns conventional_root,
|
||||
# not current: new-agent.sh's project-scope file placement always
|
||||
# uses its `$ROOT` argument directly, never the walked-up `.git`
|
||||
# location, so a <root> one or more levels below the repo's .git
|
||||
# (a subdirectory of a larger git-tracked tree — explicitly a
|
||||
# supported case per new-agent.sh's usage text) must resolve to the
|
||||
# same root new-agent.sh actually wrote to, not to the .git dir —
|
||||
# unless the path lacks the conventional shape, in which case that
|
||||
# arithmetic isn't trustworthy and current is used instead.
|
||||
if os.path.exists(os.path.join(current, '.git')):
|
||||
return 'project', conventional_root if conventional_shape else current
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
return 'project', conventional_root if conventional_shape else current
|
||||
current = parent
|
||||
|
||||
agent_dir = os.path.dirname(agent_file)
|
||||
scope, scope_root = detect_scope(agent_dir)
|
||||
|
||||
# --- Plugin/APM scope: single vendor-neutral file, no counterpart ---
|
||||
def check_apm_agent_file(fpath, allowlist, stem):
|
||||
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
|
||||
|
||||
# The apm-agent.md template embeds its authoring guidance as HTML
|
||||
# comments inside the frontmatter block (so they render invisible in a
|
||||
# Markdown preview but stay visible in the raw file). get_frontmatter_keys
|
||||
# silently ignores any line that isn't a `key:` match, so a comment left
|
||||
# behind at ship time would otherwise pass unnoticed — yet apm compile
|
||||
# copies this frontmatter verbatim to both harnesses, and `<!-- -->` is
|
||||
# not valid YAML, so yaml.safe_load breaks on both downstream (ADR-0016).
|
||||
if re.search(r'<!--|-->', fm):
|
||||
fail(f"frontmatter still contains template HTML comments (<!-- ... -->) "
|
||||
f"— delete them before shipping — {local_fname}")
|
||||
|
||||
# Allowlist: only name/description/model may appear — no tools, no
|
||||
# Claude-only or Copilot-only fields. apm compile verbatim-copies
|
||||
# frontmatter to every target, so anything else is unsafe on at least
|
||||
# one harness (ADR-0016).
|
||||
fm_keys = get_frontmatter_keys(fm)
|
||||
for key in sorted(fm_keys):
|
||||
if key not in allowlist:
|
||||
fail(f"field '{key}' is not in the vendor-neutral APM agent allowlist "
|
||||
f"({', '.join(sorted(allowlist))}) — {local_fname}")
|
||||
|
||||
# name — required, kebab-case, must match filename stem (file is <name>.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
|
||||
Reference in New Issue
Block a user