fix: preserve required skill names and validate duplicate identities

This commit is contained in:
Conor Bronsdon
2026-09-16 10:21:12 -07:00
parent 98699b3d49
commit cc748c87e5
10 changed files with 81 additions and 81 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ All notable changes to this project are documented here.
### Fixed
- Drop redundant `name` frontmatter from the generated `SKILL.full.md` and OpenAI bundled `skills/avoid-ai-writing/SKILL.md` copy; the directory name remains authoritative per Agent Skills convention (#259).
- Require explicit skill names matching their directories and reject duplicate frontmatter keys, including mixed quoted/unquoted keys, while retaining required names in every generated distribution (#259).
### Added
Generated
+1
View File
@@ -1,4 +1,5 @@
---
name: avoid-ai-writing
description: Audit and rewrite content to remove AI writing patterns ("AI-isms"). Use this skill when asked to "remove AI-isms," "clean up AI writing," "edit writing for AI patterns," "audit writing for AI tells," or "make this sound less like AI." Supports a detect-only mode, an edit-in-place mode for files, an optional voice profile (casual / professional / technical / warm / blunt), and an iterate-to-convergence pass.
version: 3.35.0
license: MIT
Generated Vendored
+1
View File
@@ -1,4 +1,5 @@
---
name: avoid-ai-writing
description: Audit and rewrite content to remove AI writing patterns ("AI-isms"). Use this skill when asked to "remove AI-isms," "clean up AI writing," "edit writing for AI patterns," "audit writing for AI tells," or "make this sound less like AI." Supports a detect-only mode, an edit-in-place mode for files, an optional voice profile (casual / professional / technical / warm / blunt), and an iterate-to-convergence pass.
version: 3.35.0
license: MIT
+1 -10
View File
@@ -1,15 +1,6 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
function stripRedundantNameFromFrontmatter(text) {
if (!text.startsWith('---\n')) return text;
const end = text.indexOf('\n---\n', 4);
if (end < 0) return text;
const inner = text.slice(4, end);
const kept = inner.split('\n').filter((line) => !/^name:/.test(line));
return `---\n${kept.join('\n')}\n---\n${text.slice(end + 5)}`;
}
function flatten(root) {
let front = fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8').replace(/\r\n/g, '\n');
const reference = fs.readFileSync(path.join(root, 'references/patterns.md'), 'utf8').replace(/\r\n/g, '\n');
@@ -25,7 +16,7 @@ function flatten(root) {
front = front.replace(marker, () => content);
}
if (/<!-- patterns:/.test(front)) throw new Error('Unknown pattern include marker');
return stripRedundantNameFromFrontmatter(front);
return front;
}
if (require.main === module) {
const root = path.resolve(__dirname, '..');
+1
View File
@@ -7,6 +7,7 @@ const {flatten} = require('./flatten-skill');
const root = path.resolve(__dirname, '..');
const normalized = p => fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
assert.equal(flatten(root), normalized(path.join(root, 'SKILL.full.md')), 'Flattened artifact must equal canonical content');
assert.match(flatten(root).split('\n---\n')[0], /^name: avoid-ai-writing$/m, 'Portable skill retains its required name');
assert.ok(normalized(path.join(root, 'SKILL.md')).split('\n').length < 500, 'Entry skill stays below 500 lines');
// Both single-file targets must carry the same portable instructions.
const portableBody = file => normalized(path.join(root, file))
+4 -5
View File
@@ -56,12 +56,11 @@ for skill_root in "$(dirname "$claude_dest")" "$canonical_skill_root"; do
done
# The OpenAI plugin portal rejects a `metadata` key in SKILL.md frontmatter
# ("Skill interface settings must use agents/openai.yaml"); that block carries
# agentskills.io/OpenClaw fields, so the OpenAI copy omits it. The bundled copy
# also omits redundant `name` (the directory name is authoritative). The
# transform lives in validate-openai-plugin.py so the sync and the drift check
# cannot diverge.
# agentskills.io/OpenClaw fields, so the OpenAI copy omits it and every other
# byte stays identical. The transform lives in validate-openai-plugin.py so the
# sync and the drift check cannot diverge.
python_bin="$(command -v python3 || command -v python)"
"$python_bin" "$repo_root/scripts/validate-openai-plugin.py" --openai-canonical-skill-copy "$src" > "$openai_dest"
"$python_bin" "$repo_root/scripts/validate-openai-plugin.py" --strip-frontmatter-metadata "$src" > "$openai_dest"
cp "$patterns_src" "$detector_patterns_dest"
cp "$patterns_src" "$verifier_patterns_dest"
cp "$validate_src" "$verifier_validate_dest"
+25 -53
View File
@@ -12,10 +12,6 @@ import xml.etree.ElementTree as ET
SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$")
FRONTMATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n(.*)\Z", re.S)
# The top-level `metadata` key only: `metadata:` at column 0 followed by
# whitespace or end of line, so `metadata:extra:` (a different plain key) is kept.
METADATA_KEY = re.compile(r"metadata:(?:\s|$)")
TOP_LEVEL_NAME_KEY = re.compile(r"name:(?:\s|$)")
TOP_LEVEL_INCLUDE_FILES = ("OPENAI_PLUGIN.md", "NOTICE.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md", "LICENSE")
CANONICAL_PROJECT_URL = "https://github.com/conorbronsdon/avoid-ai-writing"
MAX_SVG_BYTES = 256 * 1024
@@ -31,11 +27,9 @@ def parse_frontmatter(path: Path):
if not match:
return {}, ""
meta = {}
for line in match.group(1).splitlines():
if ":" not in line or line.startswith((" ", "\t")):
continue
key, value = line.split(":", 1)
meta[key.strip()] = value.strip().strip('"').strip("'")
for key, value in frontmatter_entries(match.group(1)):
parsed = parse_supported_yaml_scalar(value)
meta[key] = parsed if parsed is not None else value.strip()
return meta, match.group(2).strip()
def frontmatter_inner(text: str) -> str | None:
@@ -43,42 +37,29 @@ def frontmatter_inner(text: str) -> str | None:
return match.group(1) if match else None
def duplicate_top_level_frontmatter_keys(inner: str) -> list[str]:
"""Return top-level YAML keys that appear more than once in a frontmatter block."""
counts: dict[str, int] = {}
def frontmatter_entries(inner: str):
"""Read top-level scalar keys; normalize quoted and plain spellings alike."""
for line in inner.splitlines():
if not line or line[0] in (" ", "\t", "#"):
continue
if ":" not in line:
continue
key = line.split(":", 1)[0].strip()
match = re.fullmatch(r'''("(?:\\.|[^"\\])*"|'(?:''|[^'])*'|[A-Za-z_][A-Za-z0-9_-]*)[ \t]*:(.*)''', line)
if match:
# JSON-style quoted YAML keys permit a value directly after ':'.
if match.group(2) and not match.group(2)[0].isspace() and match.group(1)[0] not in ("'", '"'):
continue
key = parse_supported_yaml_scalar(match.group(1))
if key is not None:
yield key, match.group(2) or ""
def duplicate_top_level_frontmatter_keys(inner: str) -> list[str]:
"""Return repeated keys within one frontmatter mapping, not across copies."""
counts: dict[str, int] = {}
for key, _ in frontmatter_entries(inner):
counts[key] = counts.get(key, 0) + 1
return sorted(key for key, count in counts.items() if count > 1)
def strip_frontmatter_name(text: str) -> str:
"""Drop the top-level `name` line from SKILL.md frontmatter when the directory name is authoritative."""
match = FRONTMATTER.match(text)
if not match:
return text
kept, skip = [], False
for line in match.group(1).splitlines(keepends=True):
if not skip and line[:1] not in (" ", "\t") and TOP_LEVEL_NAME_KEY.match(line.rstrip("\r\n")):
continue
skip = False
kept.append(line)
joined = "".join(kept)
if joined and joined.endswith("\n"):
joined = joined[:-1]
start, end = match.start(1), match.end(1)
return text[:start] + joined + text[end:]
def openai_canonical_skill_copy(text: str) -> str:
"""OpenAI bundled copy: no portal-rejected metadata, no redundant `name` (directory is avoid-ai-writing)."""
return strip_frontmatter_name(strip_frontmatter_metadata(text))
def strip_frontmatter_metadata(text: str) -> str:
"""Drop the top-level `metadata` block from SKILL.md frontmatter, byte-exact otherwise.
@@ -94,7 +75,7 @@ def strip_frontmatter_metadata(text: str) -> str:
inner = match.group(1)
kept, skip = [], False
for line in inner.splitlines(keepends=True):
if METADATA_KEY.match(line):
if any(key == "metadata" for key, _ in frontmatter_entries(line)):
skip = True
continue
if skip and (line[:1] in (" ", "\t", "#") or line.strip() == ""):
@@ -116,7 +97,7 @@ def strip_frontmatter_metadata(text: str) -> str:
def frontmatter_has_metadata(path: Path) -> bool:
match = FRONTMATTER.match(path.read_text(encoding="utf-8"))
return bool(match) and any(METADATA_KEY.match(line) for line in match.group(1).split("\n"))
return bool(match) and any(key == "metadata" for key, _ in frontmatter_entries(match.group(1)))
def safe_rel(value: str) -> bool:
@@ -549,9 +530,9 @@ def validate(root: Path):
if inner:
for key in duplicate_top_level_frontmatter_keys(inner):
errors.append(f"{skill_path}: duplicate frontmatter key: {key}")
name = meta.get("name") or skill_dir.name
name = meta.get("name", "")
desc = meta.get("description", "")
if not desc or not body:
if not name or not desc or not body:
errors.append(f"{skill_path}: name, description, and body are required")
if meta.get("name") and meta.get("name") != skill_dir.name:
errors.append(
@@ -576,10 +557,10 @@ def validate(root: Path):
if canonical.is_file():
if not openai_copy.is_file():
errors.append("skills/avoid-ai-writing/SKILL.md missing; cannot check drift from root SKILL.md")
elif openai_canonical_skill_copy(canonical.read_text(encoding="utf-8")) != openai_copy.read_text(encoding="utf-8"):
elif strip_frontmatter_metadata(canonical.read_bytes().decode("utf-8")).encode("utf-8") != openai_copy.read_bytes():
errors.append(
"skills/avoid-ai-writing/SKILL.md drifted from root SKILL.md "
"(expected: root minus the frontmatter `metadata` block and redundant `name`)"
"(expected: root minus the frontmatter `metadata` block)"
)
canonical_inner = frontmatter_inner(canonical.read_text(encoding="utf-8"))
if canonical_inner:
@@ -745,20 +726,11 @@ def main():
metavar="SKILL_MD",
help="print SKILL_MD with the frontmatter `metadata` block removed and exit",
)
parser.add_argument(
"--openai-canonical-skill-copy",
metavar="SKILL_MD",
help="print the OpenAI bundled canonical skill copy (metadata and redundant name stripped) and exit",
)
args = parser.parse_args()
if args.strip_frontmatter_metadata:
data = Path(args.strip_frontmatter_metadata).read_bytes().decode("utf-8")
sys.stdout.buffer.write(strip_frontmatter_metadata(data).encode("utf-8"))
return 0
if args.openai_canonical_skill_copy:
data = Path(args.openai_canonical_skill_copy).read_bytes().decode("utf-8")
sys.stdout.buffer.write(openai_canonical_skill_copy(data).encode("utf-8"))
return 0
errors, warnings, summary = validate(Path(args.root).resolve())
if args.json:
print(json.dumps(summary, indent=2))
+45 -11
View File
@@ -95,6 +95,44 @@ def validation_errors_for(*, manifest=None, tests=None, listing=None, pack=None)
return errors
def skill_name_errors(replacement: str):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
make_valid_plugin_root(root)
skill = root / "skills" / "voice-preserving-rewriter" / "SKILL.md"
text = skill.read_text(encoding="utf-8")
original = "name: voice-preserving-rewriter"
assert original in text
skill.write_text(text.replace(original, replacement, 1), encoding="utf-8")
return MODULE.validate(root)[0]
for key in ("name", '"name"', "'name'", r'"\u006eame"'):
assert skill_name_errors(f'{key}: "voice-preserving-rewriter" # identity') == []
errors = skill_name_errors(f"{key}: wrong-name")
assert any("must match directory" in error for error in errors), errors
errors = skill_name_errors(f"name: voice-preserving-rewriter\n{key}: voice-preserving-rewriter")
assert any("duplicate frontmatter key: name" in error for error in errors), errors
for replacement in ("", "name:", "name: ''", '"name": ""'):
errors = skill_name_errors(replacement)
assert any("name, description, and body are required" in error for error in errors), errors
assert skill_name_errors('"name":"voice-preserving-rewriter"') == []
errors = skill_name_errors('name: voice-preserving-rewriter\n"name" :"wrong-name"')
assert any("duplicate frontmatter key: name" in error for error in errors), errors
assert any("must match directory" in error for error in errors), errors
errors = skill_name_errors('"name": false-positive-reviewer')
assert any("duplicate skill name 'false-positive-reviewer'" in error for error in errors), errors
assert MODULE.duplicate_top_level_frontmatter_keys(
'name: first\nmetadata:\n name: nested\n name: nested-again\n# name: comment\n'
) == []
assert MODULE.duplicate_top_level_frontmatter_keys(
'description: first\n"description": second\n'
) == ["description"]
assert errors_for(" products: [CHAT, CODEX]\n") == []
assert errors_for(' products: ["CHAT", "CODEX"]\n') == []
assert errors_for(" products: ['CHAT', 'CODEX']\n") == []
@@ -262,7 +300,7 @@ with tempfile.TemporaryDirectory() as temp_dir:
text = skill_path.read_text(encoding="utf-8")
skill_path.write_text(text.replace("name: avoid-ai-writing\n", "", 1), encoding="utf-8")
errors, _, _ = MODULE.validate(root)
assert errors == [], errors
assert any("name, description, and body are required" in error for error in errors), errors
for payload in ("[]", "null"):
with tempfile.TemporaryDirectory() as temp_dir:
@@ -373,14 +411,14 @@ with tempfile.TemporaryDirectory() as temp_dir:
assert any("symlink not allowed in plugin surface: LICENSE" in error for error in errors)
# The OpenAI copy of the canonical skill must drop the frontmatter `metadata`
# block (the portal rejects it) and the redundant `name` field.
# block (the portal rejects it) and preserve the required `name` field.
ROOT_SKILL = (REPO_ROOT / "SKILL.md").read_text(encoding="utf-8")
assert "\nmetadata:\n" in ROOT_SKILL, "fixture assumption: root SKILL.md carries a metadata block"
STRIPPED = MODULE.openai_canonical_skill_copy(ROOT_SKILL)
STRIPPED = MODULE.strip_frontmatter_metadata(ROOT_SKILL)
ROOT_HEAD, ROOT_BODY = ROOT_SKILL.split("\n---\n", 1)
STRIPPED_HEAD, STRIPPED_BODY = STRIPPED.split("\n---\n", 1)
assert "metadata:" not in STRIPPED_HEAD
assert "name:" not in STRIPPED_HEAD
assert "name: avoid-ai-writing" in STRIPPED_HEAD
assert "\nversion:" in STRIPPED_HEAD and "\nlicense:" in STRIPPED_HEAD and "\ncompatibility:" in STRIPPED_HEAD
assert STRIPPED_BODY == ROOT_BODY, "body must be untouched"
assert MODULE.strip_frontmatter_metadata("no frontmatter\nmetadata:\n x: y\n") == "no frontmatter\nmetadata:\n x: y\n"
@@ -396,24 +434,20 @@ assert STRIP("---\r\nname: x\r\nmetadata:\r\n a: b\r\nlicense: MIT\r\n---\r\nBo
# a column-zero comment inside the block belongs to it; `metadata:extra` is a different key and stays
assert STRIP("---\nname: x\nmetadata:\n author: y\n# note\n repository: z\nlicense: MIT\n---\nBody\n") == "---\nname: x\nlicense: MIT\n---\nBody\n"
assert STRIP("---\nname: x\nmetadata:extra: keep\n---\nBody\n") == "---\nname: x\nmetadata:extra: keep\n---\nBody\n"
assert STRIP('---\nname: x\n"metadata":\n author: y\n---\nBody\n') == '---\nname: x\n---\nBody\n'
# missing closing delimiter: not a frontmatter, untouched
assert STRIP("---\nname: x\nmetadata:\n author: y\nBody\n") == "---\nname: x\nmetadata:\n author: y\nBody\n"
# sync-plugin-skill.sh uses --openai-canonical-skill-copy; metadata-only strip keeps `name`
# sync-plugin-skill.sh uses metadata-only stripping and keeps `name`
import subprocess
metadata_only = subprocess.run(
[sys.executable, str(MODULE_PATH), "--strip-frontmatter-metadata", str(REPO_ROOT / "SKILL.md")],
capture_output=True, check=True,
)
assert metadata_only.stdout == MODULE.strip_frontmatter_metadata(ROOT_SKILL).encode("utf-8")
openai_copy = subprocess.run(
[sys.executable, str(MODULE_PATH), "--openai-canonical-skill-copy", str(REPO_ROOT / "SKILL.md")],
capture_output=True, check=True,
)
assert openai_copy.stdout == STRIPPED.encode("utf-8"), "CLI output differs from openai_canonical_skill_copy"
assert STRIPPED == (REPO_ROOT / "skills" / "avoid-ai-writing" / "SKILL.md").read_text(encoding="utf-8"), (
"run bash scripts/sync-plugin-skill.sh; the OpenAI copy is out of date"
)
assert "name:" not in (REPO_ROOT / "SKILL.full.md").read_text(encoding="utf-8").split("\n---\n", 1)[0]
assert "name: avoid-ai-writing" in (REPO_ROOT / "SKILL.full.md").read_text(encoding="utf-8").split("\n---\n", 1)[0]
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
+1
View File
@@ -1,4 +1,5 @@
---
name: avoid-ai-writing
description: Audit and rewrite content to remove AI writing patterns ("AI-isms"). Use this skill when asked to "remove AI-isms," "clean up AI writing," "edit writing for AI patterns," "audit writing for AI tells," or "make this sound less like AI." Supports a detect-only mode, an edit-in-place mode for files, an optional voice profile (casual / professional / technical / warm / blunt), and an iterate-to-convergence pass.
version: 3.35.0
license: MIT
+1 -1
View File
@@ -7,7 +7,7 @@
},
"architecture": "skills-only",
"publicSkills": ["avoid-ai-writing","avoid-ai-writing-router","ai-writing-detector","voice-preserving-rewriter","file-edit-in-place","preservation-verifier","false-positive-reviewer"],
"canonicalSkill": {"path":"skills/avoid-ai-writing/SKILL.md","source":"SKILL.md","preservation":"generated copy, identical except the frontmatter metadata block and redundant name field are omitted (OpenAI portal rejects metadata; directory name is authoritative)"},
"canonicalSkill": {"path":"skills/avoid-ai-writing/SKILL.md","source":"SKILL.md","preservation":"generated copy, identical except the frontmatter metadata block is omitted (OpenAI portal rejects it)"},
"bundledDeterministicResources": [
{"path":"skills/ai-writing-detector/scripts/patterns.js","source":"detector/patterns.js","purpose":"detect-only local analysis"},
{"path":"skills/preservation-verifier/scripts/validate.js","source":"detector/validate.js","purpose":"before/after preservation verification"},