feat(ci): lint skills for Codex-safe frontmatter and Claude substitutions

Add scripts/ci/validate-skill-host-compat.js and run it from the Validate
Components job and npm test after validate-skills.js. The check fails on
Claude-only substitutions in bash fences and on Codex copies that use keys
outside name, description, metadata, license, and allowed-tools. CONTRIBUTING
checklist items warn unless --strict-checklist.

Also add /skill-host-compat and skills/skill-host-compat with a Codex copy
that drops origin. Move configure-ecc plugin-root examples into prose so the
live catalog stays green without a skip list.

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-09-20 16:17:20 +00:00
co-authored by Matt Van Horn
parent 934195f955
commit 0895f0d8b5
18 changed files with 1272 additions and 26 deletions
+97
View File
@@ -0,0 +1,97 @@
---
name: skill-host-compat
description: Lint ECC skills for Codex/Cursor-safe frontmatter and Claude-only substitutions before a skill PR.
---
# Skill Host Compatibility
Run the same host-compat gate CI uses on `skills/` before opening a skill PR.
The check is for Codex and Cursor copies, not Claude-only runtime quality.
## When to Activate
- Adding or editing a skill under `skills/`
- Copying a skill into `.agents/skills/` or `.cursor/skills/`
- A review asks whether a skill is safe to install into Codex or Cursor
- CI reports `validate-skill-host-compat.js` findings
## Core Concepts
Canonical skills live in `skills/`. Codex loads `.agents/skills/`. Cursor loads a
smaller `.cursor/skills/` subset. CONTRIBUTING documents that sync as manual.
`validate-skills.js` only checks that `SKILL.md` exists and that `name` /
`description` are usable YAML. `tests/ci/codex-skill-surface.test.js` only
sees `.agents/skills/`. This script is the cross-tree gate:
1. Claude-only substitutions in executable `bash` / `sh` / `zsh` fences
(`$CLAUDE_SESSION_ID`, `$CLAUDE_SKILL_DIR`, `$CLAUDE_PLUGIN_ROOT`,
`$ARGUMENTS`) fail. Codex leaves those tokens as literal text.
2. A Codex copy may only use `name`, `description`, `metadata`, `license`,
and `allowed-tools`. `origin: ECC` may stay on the canonical `skills/`
copy and must be stripped on the Codex copy.
3. A skill listed in `agents/openai.yaml` that has a `skills/` directory must
have `.agents/skills/<name>/`. Cursor copies are optional; when present,
`name:` must match the directory.
4. CONTRIBUTING checklist items (When to Activate, Anti-Patterns, name
matches directory, 500/800 line caps) warn. They fail only with
`--strict-checklist`.
`validate-skills.js` frontmatter WARN default is unchanged (#1663).
## Code Examples
From the repo root:
```bash
node scripts/ci/validate-skill-host-compat.js
```
Print counts without failing:
```bash
node scripts/ci/validate-skill-host-compat.js --inventory
```
Promote checklist warnings:
```bash
node scripts/ci/validate-skill-host-compat.js --strict-checklist
```
Slash command:
```
$skill-host-compat
$skill-host-compat --inventory
```
## Anti-Patterns
Do not put Claude-only substitutions in bash blocks. This fence is the
forbidden pattern:
```bash
echo "$CLAUDE_SESSION_ID"
ls "$CLAUDE_SKILL_DIR"
node "$CLAUDE_PLUGIN_ROOT/scripts/setup.js" $ARGUMENTS
```
Do not add `version:` or `origin:` to a Codex copy under `.agents/skills/`.
Do not treat a passing `validate-skills.js` run as Codex-safe. Do not require
every `skills/` entry to have a Cursor copy.
## Best Practices
- Keep canonical `origin: ECC` if the CONTRIBUTING template calls for it.
- Drop `origin`, `version`, and `argument-hint` on the Codex copy.
- Show host-portable commands in bash fences (`npx`, `ecc`, or a resolved
root). Mention Claude env vars in prose, not in executable fences.
- Run this script before `npm test` when the change is a new skill.
- Use `--inventory` to see checklist warning volume without failing the tree.
## Related Skills
- `skill-comply` — runtime behavioral traces, not host-portability
- `skill-stocktake` — holistic audit of installed `~/.claude/skills`
- `skill-scout` — search existing skills before creating a new one
@@ -0,0 +1,7 @@
interface:
display_name: "Skill Host Compat"
short_description: "Lint skills for Codex-safe frontmatter and substitutions"
brand_color: "#6366F1"
default_prompt: "Use $skill-host-compat to lint skills for Codex/Cursor-safe frontmatter and Claude-only substitutions."
policy:
allow_implicit_invocation: true
+4
View File
@@ -211,6 +211,10 @@ jobs:
run: node scripts/ci/validate-skills.js
continue-on-error: false
- name: Validate skill host compatibility
run: node scripts/ci/validate-skill-host-compat.js
continue-on-error: false
- name: Validate install manifests
run: node scripts/ci/validate-install-manifests.js
continue-on-error: false
+3
View File
@@ -41,6 +41,9 @@ jobs:
- name: Validate skills
run: node scripts/ci/validate-skills.js
- name: Validate skill host compatibility
run: node scripts/ci/validate-skill-host-compat.js
- name: Validate install manifests
run: node scripts/ci/validate-install-manifests.js
+1
View File
@@ -180,6 +180,7 @@ These lifecycle commands are also available through the `ecc` CLI.
| `/instinct-import` | Import instincts from a file or URL |
| `/skill-create` | Analyse local git history → generate a reusable skill |
| `/skill-health` | Skill portfolio health dashboard with analytics |
| `/skill-host-compat` | Lint skills for Codex-safe frontmatter and Claude substitutions |
---
+2
View File
@@ -912,6 +912,7 @@ ECC/
| |-- swift-protocol-di-testing/ # Protocol-based DI for testable Swift code
| |-- search-first/ # Research-before-coding workflow
| |-- skill-stocktake/ # Audit skills and commands for quality
| |-- skill-host-compat/ # Lint skills for Codex-safe frontmatter and substitutions
| |-- liquid-glass-design/ # iOS 26 Liquid Glass design system
| |-- foundation-models-on-device/ # Apple on-device LLM with FoundationModels
| |-- swift-concurrency-6-2/ # Swift 6.2 Approachable Concurrency
@@ -938,6 +939,7 @@ ECC/
| |-- go-test.md # /go-test - Go TDD workflow
| |-- go-build.md # /go-build - Fix Go build errors
| |-- skill-create.md # /skill-create - Generate skills from git history
| |-- skill-host-compat.md # /skill-host-compat - Lint skills for Codex/Cursor copies
| |-- instinct-status.md # /instinct-status - View learned instincts
| |-- instinct-import.md # /instinct-import - Import instincts
| |-- instinct-export.md # /instinct-export - Export instincts
+2
View File
@@ -142,6 +142,7 @@ skills:
- security-review
- security-scan
- skill-comply
- skill-host-compat
- skill-stocktake
- springboot-patterns
- springboot-security
@@ -255,6 +256,7 @@ commands:
- setup-pm
- skill-create
- skill-health
- skill-host-compat
- test-coverage
- update-codemaps
- update-docs
+40
View File
@@ -0,0 +1,40 @@
---
name: skill-host-compat
description: Lint ECC skills for Codex/Cursor-safe frontmatter and Claude-only substitutions before a skill PR.
command: true
---
# Skill Host Compatibility
Lint curated `skills/` for Codex-safe frontmatter and Claude-only substitutions.
## Implementation
Run the host-compat validator:
```bash
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();console.log(r)}")}"
node "$ECC_ROOT/scripts/ci/validate-skill-host-compat.js"
```
Inventory counts without failing:
```bash
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();console.log(r)}")}"
node "$ECC_ROOT/scripts/ci/validate-skill-host-compat.js" --inventory
```
## Usage
```
/skill-host-compat # Fail on substitutions and Codex drift
/skill-host-compat --inventory # JSON counts, exit 0
/skill-host-compat --strict-checklist
```
## What to Do
1. Run `scripts/ci/validate-skill-host-compat.js` from the ECC root
2. Show ERROR and WARN lines to the user
3. For substitution errors, move Claude env vars out of bash fences
4. For Codex-key errors, drop `origin` / `version` / `argument-hint` on the copy
+2
View File
@@ -37,6 +37,7 @@ This document lists each slash command and the primary agent(s) or skills it inv
| `/promote` | — | continuous-learning-v2 |
| `/projects` | — | continuous-learning-v2 |
| `/skill-create` | — | skill-create-output script, git history |
| `/skill-host-compat` | — | validate-skill-host-compat.js, Codex/Cursor skill lint |
| `/checkpoint` | — | verification-loop skill |
| `/verify` | — | verification-loop skill |
| `/eval` | — | eval-harness skill |
@@ -71,6 +72,7 @@ This document lists each slash command and the primary agent(s) or skills it inv
- **verification-loop**: `/checkpoint`, `/verify`
- **eval-harness**: `/eval`
- **security-scan**: `/security-scan` (runs AgentShield)
- **skill-host-compat**: `/skill-host-compat`
- **strategic-compact**: suggested at compaction points (hooks)
- **unified-memory**: `ecc memory ...` and the opt-in `ecc-memory-mcp` server
+13 -2
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"totalCommands": 94,
"totalCommands": 95,
"commands": [
{
"command": "aside",
@@ -981,6 +981,17 @@
"skills": [],
"path": "commands/skill-health.md"
},
{
"command": "skill-host-compat",
"description": "Lint ECC skills for Codex/Cursor-safe frontmatter and Claude-only substitutions before a skill PR.",
"type": "general",
"primaryAgents": [],
"allAgents": [],
"skills": [
"skill-host-compat"
],
"path": "commands/skill-host-compat.md"
},
{
"command": "test-coverage",
"description": "Analyze coverage, identify gaps, and generate missing tests toward the target threshold.",
@@ -1029,7 +1040,7 @@
"statistics": {
"byType": {
"build": 2,
"general": 10,
"general": 11,
"orchestration": 11,
"planning": 2,
"refactoring": 1,
+4 -1
View File
@@ -63,7 +63,9 @@
"paths": [
"commands",
"scripts/harness-audit.js",
"scripts/skills-health.js"
"scripts/skills-health.js",
"scripts/ci/validate-skill-host-compat.js",
"scripts/ci/validate-skills.js"
],
"targets": [
"claude",
@@ -334,6 +336,7 @@
"skills/production-audit",
"skills/skill-comply",
"skills/skill-scout",
"skills/skill-host-compat",
"skills/skill-stocktake",
"skills/strategic-compact",
"skills/tdd-workflow",
+4 -1
View File
@@ -105,6 +105,8 @@
"scripts/catalog.js",
"scripts/ci/scan-supply-chain-iocs.js",
"scripts/ci/supply-chain-advisory-sources.js",
"scripts/ci/validate-skill-host-compat.js",
"scripts/ci/validate-skills.js",
"scripts/consult.js",
"scripts/auto-update.js",
"scripts/claw.js",
@@ -337,6 +339,7 @@
"skills/seo/",
"skills/skill-scout/",
"skills/skill-comply/",
"skills/skill-host-compat/",
"skills/skill-stocktake/",
"skills/social-graph-ranker/",
"skills/springboot-patterns/",
@@ -491,7 +494,7 @@
"orchestrate:status": "node scripts/orchestration-status.js",
"orchestrate:worker": "bash scripts/orchestrate-codex-worker.sh",
"orchestrate:tmux": "node scripts/orchestrate-worktrees.js",
"test": "node scripts/ci/check-unicode-safety.js && node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node scripts/ci/check-hooks-schema-keys.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-no-personal-paths.js && npm run catalog:check && npm run command-registry:check && node tests/run-all.js",
"test": "node scripts/ci/check-unicode-safety.js && node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-skill-host-compat.js && node scripts/ci/validate-hooks.js && node scripts/ci/check-hooks-schema-keys.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-no-personal-paths.js && npm run catalog:check && npm run command-registry:check && node tests/run-all.js",
"coverage": "c8 --all --include=\"scripts/**/*.js\" --include=\"scripts/**/*.mjs\" --check-coverage --lines 80 --functions 80 --branches 79 --statements 80 --reporter=text --reporter=lcov node tests/run-all.js",
"build:opencode": "node scripts/build-opencode.js",
"prepack": "npm run build:opencode",
+479
View File
@@ -0,0 +1,479 @@
#!/usr/bin/env node
/**
* Lint curated skills for host-portable frontmatter and substitutions.
*
* Checks:
* 1. Claude-only substitutions in executable bash/sh/zsh fences
* (${CLAUDE_SESSION_ID}, ${CLAUDE_SKILL_DIR}, ${CLAUDE_PLUGIN_ROOT},
* $ARGUMENTS). Codex and a bare shell leave those tokens literal.
* 2. Codex-copy frontmatter allowlist on .agents/skills/<name>/SKILL.md
* (name, description, metadata, license, allowed-tools). origin/version
* may remain on the canonical skills/ copy.
* 3. Subset drift: agents/openai.yaml names a skill that is missing from
* .agents/skills/, or a Codex/Cursor copy name: does not match its
* directory.
* 4. CONTRIBUTING checklist (WARN unless --strict-checklist): When to
* Activate, Anti-Patterns, name matches directory, 500/800 line caps.
*
* Default invocation is fail-closed on (1)-(3). Pass --inventory to print
* JSON counts and exit 0. Do not flip validate-skills.js to --strict;
* that file's WARN default is load-bearing for #1663.
*
* Scope: curated skills/ plus Codex/Cursor copies. If skills/ is absent,
* exit 0 (nothing to validate).
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { extractFrontmatter, inspectFrontmatter } = require('./validate-skills');
const DEFAULT_ROOT = path.join(__dirname, '../..');
const ALLOWED_CODEX_FRONTMATTER_KEYS = new Set([
'allowed-tools',
'description',
'license',
'metadata',
'name',
]);
const CLAUDE_SUBSTITUTION_PATTERNS = [
{ token: '${CLAUDE_SESSION_ID}', regex: /\$\{CLAUDE_SESSION_ID\}/g },
{ token: '$CLAUDE_SESSION_ID', regex: /\$CLAUDE_SESSION_ID\b/g },
{ token: '${CLAUDE_SKILL_DIR}', regex: /\$\{CLAUDE_SKILL_DIR\}/g },
{ token: '$CLAUDE_SKILL_DIR', regex: /\$CLAUDE_SKILL_DIR\b/g },
{ token: '${CLAUDE_PLUGIN_ROOT}', regex: /\$\{CLAUDE_PLUGIN_ROOT\}/g },
{ token: '$CLAUDE_PLUGIN_ROOT', regex: /\$CLAUDE_PLUGIN_ROOT\b/g },
{ token: '$ARGUMENTS', regex: /\$ARGUMENTS\b|\$\{ARGUMENTS\}/g },
// Bare $1 is a Perl/awk capture in many examples; flag the Claude skill
// form ${1} and $1 used as a shell positional, not `$1 <number>`.
{ token: '$1', regex: /\$\{1\}|(?<![\w$])\$1(?!\d)(?!\s*[<>=])/g },
];
const EXECUTABLE_FENCE_LANGS = new Set(['bash', 'sh', 'zsh']);
const CHECKLIST_CODES = new Set([
'missing-when-to-activate',
'missing-anti-patterns',
'name-mismatch',
'line-cap-500',
'line-cap-800',
]);
function parseArgs(argv) {
const args = {
root: DEFAULT_ROOT,
inventory: false,
strictChecklist: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--inventory') {
args.inventory = true;
} else if (arg === '--strict-checklist') {
args.strictChecklist = true;
} else if (arg === '--root') {
const value = argv[i + 1];
if (!value) {
throw new Error('--root requires a directory path');
}
args.root = value;
i += 1;
}
}
return args;
}
function listSkillDirs(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true })
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
.map(entry => entry.name)
.sort();
}
function readFileIfPresent(filePath) {
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath, 'utf8');
}
function unquoteYamlScalar(value) {
if (value === undefined || value === null) return '';
const trimmed = String(value).trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function extractTopLevelList(yamlSource, key) {
const lines = String(yamlSource).replace(/^\uFEFF/, '').split(/\r?\n/);
const results = [];
let collecting = false;
for (const line of lines) {
if (!collecting) {
if (line.trim() === `${key}:`) {
collecting = true;
}
continue;
}
if (/^[A-Za-z0-9_-]+:\s*/.test(line)) {
break;
}
const match = line.match(/^\s*-\s+(.+?)\s*$/);
if (match) {
results.push(unquoteYamlScalar(match[1]));
}
}
return results;
}
function listOpenaiYamlSkills(root) {
const filePath = path.join(root, 'agents', 'openai.yaml');
const source = readFileIfPresent(filePath);
if (source === null) return [];
return extractTopLevelList(source, 'skills');
}
function stripFrontmatter(content) {
const fm = extractFrontmatter(content);
if (!fm.present) {
return { frontmatter: fm, body: content.replace(/^\uFEFF/, ''), bodyOffset: 0 };
}
const match = content.replace(/^\uFEFF/, '').match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/);
const bodyOffset = match ? match[0].length : 0;
return {
frontmatter: fm,
body: content.replace(/^\uFEFF/, '').slice(bodyOffset),
bodyOffset,
};
}
function findExecutableFences(body) {
const fences = [];
const fenceRe = /^```([^\r\n]*)\r?\n([\s\S]*?)^```[ \t]*(?:\r?\n|$)/gm;
let match = fenceRe.exec(body);
while (match) {
const info = (match[1] || '').trim();
const lang = info === '' ? '' : info.split(/\s+/)[0].toLowerCase();
const content = match[2];
const executable = EXECUTABLE_FENCE_LANGS.has(lang)
|| (lang === '' && /^\s*#!/.test(content));
if (executable) {
fences.push({
lang: lang || 'shebang',
content,
start: match.index,
});
}
match = fenceRe.exec(body);
}
return fences;
}
function precedingAllowsToken(body, fenceStart) {
const prefix = body.slice(0, fenceStart).replace(/\s+$/, '');
const lines = prefix.split(/\r?\n/);
const window = lines.slice(-3).join('\n');
return /anti-pattern/i.test(window) || /do not/i.test(window);
}
function findSubstitutionTokens(fenceContent) {
const found = [];
for (const pattern of CLAUDE_SUBSTITUTION_PATTERNS) {
pattern.regex.lastIndex = 0;
if (pattern.regex.test(fenceContent)) {
found.push(pattern.token);
}
}
return found;
}
function countLines(content) {
if (content.length === 0) return 0;
return content.replace(/^\uFEFF/, '').split(/\r?\n/).length;
}
function relSkillMd(skillName) {
return `${skillName}/SKILL.md`;
}
function pushFinding(findings, finding) {
findings.push(finding);
}
function inspectCanonicalSkill(skillName, content, findings) {
const { frontmatter, body } = stripFrontmatter(content);
const values = frontmatter.present ? inspectFrontmatter(frontmatter.lines).values : {};
const declaredName = unquoteYamlScalar(values.name || '');
const lineCount = countLines(content);
if (declaredName && declaredName !== skillName) {
pushFinding(findings, {
severity: 'warn',
code: 'name-mismatch',
skill: skillName,
message: `${relSkillMd(skillName)} - frontmatter name '${declaredName}' does not match directory '${skillName}'`,
});
}
if (!/^##\s+When to Activate\b/m.test(body)) {
pushFinding(findings, {
severity: 'warn',
code: 'missing-when-to-activate',
skill: skillName,
message: `${relSkillMd(skillName)} - missing '## When to Activate' section`,
});
}
if (!/^##\s+Anti-Patterns\b/m.test(body)) {
pushFinding(findings, {
severity: 'warn',
code: 'missing-anti-patterns',
skill: skillName,
message: `${relSkillMd(skillName)} - missing '## Anti-Patterns' section`,
});
}
if (lineCount > 800) {
pushFinding(findings, {
severity: 'warn',
code: 'line-cap-800',
skill: skillName,
message: `${relSkillMd(skillName)} - body is ${lineCount} lines (CONTRIBUTING max 800)`,
});
} else if (lineCount > 500) {
pushFinding(findings, {
severity: 'warn',
code: 'line-cap-500',
skill: skillName,
message: `${relSkillMd(skillName)} - body is ${lineCount} lines (CONTRIBUTING target 500)`,
});
}
for (const fence of findExecutableFences(body)) {
if (precedingAllowsToken(body, fence.start)) continue;
const tokens = findSubstitutionTokens(fence.content);
for (const token of tokens) {
pushFinding(findings, {
severity: 'error',
code: 'substitution',
skill: skillName,
token,
message: `${relSkillMd(skillName)} - ${fence.lang} fence contains Claude-only substitution ${token}`,
});
}
}
}
function inspectCopyName(skillName, content, treeLabel, findings) {
const { frontmatter } = stripFrontmatter(content);
if (!frontmatter.present) return;
const declaredName = unquoteYamlScalar(inspectFrontmatter(frontmatter.lines).values.name || '');
if (declaredName && declaredName !== skillName) {
pushFinding(findings, {
severity: 'error',
code: 'copy-name-mismatch',
skill: skillName,
message: `${treeLabel}/${relSkillMd(skillName)} - frontmatter name '${declaredName}' does not match directory '${skillName}'`,
});
}
}
function inspectCodexCopy(skillName, content, findings) {
const { frontmatter } = stripFrontmatter(content);
inspectCopyName(skillName, content, '.agents/skills', findings);
if (!frontmatter.present) return;
const keys = Object.keys(inspectFrontmatter(frontmatter.lines).values);
const unexpected = keys.filter(key => !ALLOWED_CODEX_FRONTMATTER_KEYS.has(key));
if (unexpected.length > 0) {
pushFinding(findings, {
severity: 'error',
code: 'codex-key',
skill: skillName,
message: `.agents/skills/${relSkillMd(skillName)} - Codex-forbidden frontmatter key${unexpected.length === 1 ? '' : 's'}: ${unexpected.join(', ')}`,
});
}
}
function validateSkillHostCompat(options = {}) {
const root = options.root || DEFAULT_ROOT;
const inventory = Boolean(options.inventory);
const strictChecklist = Boolean(options.strictChecklist);
const skillsDir = path.join(root, 'skills');
const codexDir = path.join(root, '.agents', 'skills');
const cursorDir = path.join(root, '.cursor', 'skills');
const findings = [];
if (!fs.existsSync(skillsDir)) {
return {
ok: true,
skipped: true,
message: 'No curated skills directory (skills/), skipping',
findings: [],
counts: emptyCounts(),
};
}
const canonicalSkills = listSkillDirs(skillsDir);
const codexSkills = listSkillDirs(codexDir);
const cursorSkills = listSkillDirs(cursorDir);
const openaiListed = listOpenaiYamlSkills(root);
const skillsWithCanonical = new Set(canonicalSkills);
for (const skillName of canonicalSkills) {
const skillMd = path.join(skillsDir, skillName, 'SKILL.md');
const content = readFileIfPresent(skillMd);
if (content === null) continue;
inspectCanonicalSkill(skillName, content, findings);
}
for (const skillName of openaiListed) {
if (!skillsWithCanonical.has(skillName)) continue;
const copyMd = path.join(codexDir, skillName, 'SKILL.md');
if (!fs.existsSync(copyMd)) {
pushFinding(findings, {
severity: 'error',
code: 'missing-codex-copy',
skill: skillName,
message: `agents/openai.yaml lists '${skillName}' but .agents/skills/${skillName}/ is missing`,
});
}
}
for (const skillName of codexSkills) {
const copyMd = path.join(codexDir, skillName, 'SKILL.md');
const content = readFileIfPresent(copyMd);
if (content === null) continue;
inspectCodexCopy(skillName, content, findings);
}
for (const skillName of cursorSkills) {
const copyMd = path.join(cursorDir, skillName, 'SKILL.md');
const content = readFileIfPresent(copyMd);
if (content === null) continue;
inspectCopyName(skillName, content, '.cursor/skills', findings);
}
const resolved = findings.map(finding => {
if (strictChecklist && CHECKLIST_CODES.has(finding.code) && finding.severity === 'warn') {
return { ...finding, severity: 'error' };
}
return finding;
});
const counts = summarizeCounts(canonicalSkills.length, resolved);
const errorCount = resolved.filter(finding => finding.severity === 'error').length;
return {
ok: inventory || errorCount === 0,
skipped: false,
inventory,
findings: resolved,
counts,
message: inventory
? JSON.stringify(counts)
: formatSummary(canonicalSkills.length, counts),
};
}
function emptyCounts() {
return {
skillsScanned: 0,
errors: 0,
warnings: 0,
substitutions: 0,
codexKeys: 0,
missingCodexCopies: 0,
copyNameMismatches: 0,
checklistWarnings: 0,
};
}
function summarizeCounts(skillsScanned, findings) {
const counts = emptyCounts();
counts.skillsScanned = skillsScanned;
for (const finding of findings) {
if (finding.severity === 'error') counts.errors += 1;
if (finding.severity === 'warn') counts.warnings += 1;
if (finding.code === 'substitution') counts.substitutions += 1;
if (finding.code === 'codex-key') counts.codexKeys += 1;
if (finding.code === 'missing-codex-copy') counts.missingCodexCopies += 1;
if (finding.code === 'copy-name-mismatch') counts.copyNameMismatches += 1;
if (CHECKLIST_CODES.has(finding.code)) counts.checklistWarnings += 1;
}
return counts;
}
function formatSummary(skillCount, counts) {
let msg = `Validated host compatibility for ${skillCount} skill directories`;
const extras = [];
if (counts.errors > 0) extras.push(`${counts.errors} error${counts.errors === 1 ? '' : 's'}`);
if (counts.warnings > 0) extras.push(`${counts.warnings} warning${counts.warnings === 1 ? '' : 's'}`);
if (extras.length > 0) msg += ` (${extras.join(', ')})`;
return msg;
}
function reportFindings(result, stdout, stderr) {
if (result.skipped) {
stdout.write(`${result.message}\n`);
return;
}
if (result.inventory) {
stdout.write(`${result.message}\n`);
return;
}
for (const finding of result.findings) {
const line = `${finding.severity === 'error' ? 'ERROR' : 'WARN'}: ${finding.message}\n`;
if (finding.severity === 'error') stderr.write(line);
else stderr.write(line);
}
stdout.write(`${result.message}\n`);
}
function run(argv = process.argv.slice(2), options = {}) {
const stdout = options.stdout || process.stdout;
const stderr = options.stderr || process.stderr;
try {
const args = parseArgs(argv);
const result = validateSkillHostCompat({
root: options.root || args.root,
inventory: args.inventory,
strictChecklist: args.strictChecklist,
});
reportFindings(result, stdout, stderr);
return result.ok ? 0 : 1;
} catch (error) {
stderr.write(`ERROR: ${error.message}\n`);
return 1;
}
}
if (require.main === module) {
process.exit(run());
}
module.exports = {
ALLOWED_CODEX_FRONTMATTER_KEYS,
CLAUDE_SUBSTITUTION_PATTERNS,
extractTopLevelList,
findExecutableFences,
parseArgs,
run,
validateSkillHostCompat,
};
+12 -5
View File
@@ -379,9 +379,16 @@ function validateSkills() {
console.log(msg);
}
try {
validateSkills();
} catch (error) {
console.error(`ERROR: ${error.message}`);
process.exit(1);
module.exports = {
extractFrontmatter,
inspectFrontmatter,
};
if (require.main === module) {
try {
validateSkills();
} catch (error) {
console.error(`ERROR: ${error.message}`);
process.exit(1);
}
}
+7 -17
View File
@@ -73,15 +73,10 @@ the selected install scope.
### 3. Preview and confirm once
Prefer the plugin-bundled setup script. Substitute the two selected values and
include `--move-scope` only for a scope migration:
```bash
node "$CLAUDE_PLUGIN_ROOT/scripts/setup.js" --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --dry-run --json
```
If `$CLAUDE_PLUGIN_ROOT` is unavailable, use the published npm package:
Prefer the plugin-bundled setup script. When `$CLAUDE_PLUGIN_ROOT` is set, run
`node` on `scripts/setup.js` under that plugin root. Substitute the two selected
values and include `--move-scope` only for a scope migration. If the plugin root
is unavailable, use the published npm package:
```bash
npx --yes --package ecc-universal ecc setup --mode claude-plugin \
@@ -96,14 +91,9 @@ harness shell tool because that shell is commonly non-TTY.
### 4. Apply the explicit choices
After confirmation, rerun the same route without `--dry-run`. Keep every choice
explicit and request JSON so success can be checked deterministically:
```bash
node "$CLAUDE_PLUGIN_ROOT/scripts/setup.js" --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --yes --json
```
Fallback:
explicit and request JSON so success can be checked deterministically. When
`$CLAUDE_PLUGIN_ROOT` is set, that is `node` plus `scripts/setup.js` with
`--yes --json`. Otherwise:
```bash
npx --yes --package ecc-universal ecc setup --mode claude-plugin \
+98
View File
@@ -0,0 +1,98 @@
---
name: skill-host-compat
description: Lint ECC skills for Codex/Cursor-safe frontmatter and Claude-only substitutions before a skill PR.
origin: ECC
---
# Skill Host Compatibility
Run the same host-compat gate CI uses on `skills/` before opening a skill PR.
The check is for Codex and Cursor copies, not Claude-only runtime quality.
## When to Activate
- Adding or editing a skill under `skills/`
- Copying a skill into `.agents/skills/` or `.cursor/skills/`
- A review asks whether a skill is safe to install into Codex or Cursor
- CI reports `validate-skill-host-compat.js` findings
## Core Concepts
Canonical skills live in `skills/`. Codex loads `.agents/skills/`. Cursor loads a
smaller `.cursor/skills/` subset. CONTRIBUTING documents that sync as manual.
`validate-skills.js` only checks that `SKILL.md` exists and that `name` /
`description` are usable YAML. `tests/ci/codex-skill-surface.test.js` only
sees `.agents/skills/`. This script is the cross-tree gate:
1. Claude-only substitutions in executable `bash` / `sh` / `zsh` fences
(`${CLAUDE_SESSION_ID}`, `${CLAUDE_SKILL_DIR}`, `${CLAUDE_PLUGIN_ROOT}`,
`$ARGUMENTS`) fail. Codex leaves those tokens as literal text.
2. A Codex copy may only use `name`, `description`, `metadata`, `license`,
and `allowed-tools`. `origin: ECC` may stay on the canonical `skills/`
copy and must be stripped on the Codex copy.
3. A skill listed in `agents/openai.yaml` that has a `skills/` directory must
have `.agents/skills/<name>/`. Cursor copies are optional; when present,
`name:` must match the directory.
4. CONTRIBUTING checklist items (When to Activate, Anti-Patterns, name
matches directory, 500/800 line caps) warn. They fail only with
`--strict-checklist`.
`validate-skills.js` frontmatter WARN default is unchanged (#1663).
## Code Examples
From the repo root:
```bash
node scripts/ci/validate-skill-host-compat.js
```
Print counts without failing:
```bash
node scripts/ci/validate-skill-host-compat.js --inventory
```
Promote checklist warnings:
```bash
node scripts/ci/validate-skill-host-compat.js --strict-checklist
```
Slash command:
```
/skill-host-compat
/skill-host-compat --inventory
```
## Anti-Patterns
Do not put Claude-only substitutions in bash blocks. This fence is the
forbidden pattern:
```bash
echo "${CLAUDE_SESSION_ID}"
ls "${CLAUDE_SKILL_DIR}"
node "${CLAUDE_PLUGIN_ROOT}/scripts/setup.js" $ARGUMENTS
```
Do not add `version:` or `origin:` to a Codex copy under `.agents/skills/`.
Do not treat a passing `validate-skills.js` run as Codex-safe. Do not require
every `skills/` entry to have a Cursor copy.
## Best Practices
- Keep canonical `origin: ECC` if the CONTRIBUTING template calls for it.
- Drop `origin`, `version`, and `argument-hint` on the Codex copy.
- Show host-portable commands in bash fences (`npx`, `ecc`, or a resolved
root). Mention Claude env vars in prose, not in executable fences.
- Run this script before `npm test` when the change is a new skill.
- Use `--inventory` to see checklist warning volume without failing the tree.
## Related Skills
- `skill-comply` — runtime behavioral traces, not host-portability
- `skill-stocktake` — holistic audit of installed `~/.claude/skills`
- `skill-scout` — search existing skills before creating a new one
+494
View File
@@ -0,0 +1,494 @@
#!/usr/bin/env node
/**
* Fixture tests for scripts/ci/validate-skill-host-compat.js.
*
* Uses tmp dirs, not the live catalog.
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.join(__dirname, '..', '..');
const validatorPath = path.join(repoRoot, 'scripts', 'ci', 'validate-skill-host-compat.js');
function test(name, fn) {
try {
fn();
console.log(` ✓ ${name}`);
return true;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function createRoot() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'skill-host-compat-'));
}
function cleanup(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
function writeSkill(root, skillName, body, options = {}) {
const tree = options.tree || 'skills';
const extraFrontmatter = options.frontmatter || {};
const dir = path.join(root, tree, skillName);
fs.mkdirSync(dir, { recursive: true });
const lines = ['---', `name: ${options.name || skillName}`];
if (options.description !== false) {
lines.push(`description: ${options.description || `Fixture skill ${skillName}`}`);
}
for (const [key, value] of Object.entries(extraFrontmatter)) {
lines.push(`${key}: ${value}`);
}
lines.push('---', '');
lines.push(body);
fs.writeFileSync(path.join(dir, 'SKILL.md'), `${lines.join('\n')}\n`);
}
function writeOpenaiYaml(root, skillNames) {
const dir = path.join(root, 'agents');
fs.mkdirSync(dir, { recursive: true });
const body = ['skills:', ...skillNames.map(name => ` - ${name}`)].join('\n');
fs.writeFileSync(path.join(dir, 'openai.yaml'), `${body}\n`);
}
function runValidator(root, extraArgs = []) {
return spawnSync('node', [validatorPath, '--root', root, ...extraArgs], {
encoding: 'utf8',
cwd: repoRoot,
timeout: 10000,
});
}
function checklistBody(title) {
return [
`# ${title}`,
'',
'## When to Activate',
'',
'- Writing or reviewing a skill PR',
'',
'## Anti-Patterns',
'',
'- Shipping a Codex copy with extra frontmatter keys',
'',
].join('\n');
}
console.log('\n=== Testing validate-skill-host-compat.js ===\n');
let passed = 0;
let failed = 0;
function check(name, fn) {
if (test(name, fn)) passed += 1;
else failed += 1;
}
check('exports extractFrontmatter and inspectFrontmatter without running the CLI', () => {
const skills = require('../../scripts/ci/validate-skills');
assert.strictEqual(typeof skills.extractFrontmatter, 'function');
assert.strictEqual(typeof skills.inspectFrontmatter, 'function');
const fm = skills.extractFrontmatter('---\nname: demo\ndescription: hi\n---\n# Demo\n');
assert.strictEqual(fm.present, true);
const inspected = skills.inspectFrontmatter(fm.lines);
assert.strictEqual(inspected.values.name, 'demo');
});
check('happy: skill with name/description only, no Codex copy required, exits 0', () => {
const root = createRoot();
try {
writeSkill(root, 'portable', checklistBody('Portable'));
const result = runValidator(root);
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
assert.ok(result.stdout.includes('Validated host compatibility for 1'));
} finally {
cleanup(root);
}
});
check('happy: Codex copy whose frontmatter is the allowlist only, exits 0', () => {
const root = createRoot();
try {
writeSkill(root, 'portable', checklistBody('Portable'), {
frontmatter: { origin: 'ECC' },
});
writeSkill(root, 'portable', checklistBody('Portable'), {
tree: '.agents/skills',
});
writeOpenaiYaml(root, ['portable']);
const result = runValidator(root);
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
} finally {
cleanup(root);
}
});
check('fail: bash fence contains ${CLAUDE_SESSION_ID}', () => {
const root = createRoot();
try {
writeSkill(
root,
'leaky',
`${checklistBody('Leaky')}\n## Code Examples\n\n\`\`\`bash\necho "\${CLAUDE_SESSION_ID}"\n\`\`\`\n`
);
const result = runValidator(root);
assert.strictEqual(result.status, 1);
assert.ok(result.stderr.includes('ERROR:'));
assert.ok(result.stderr.includes('leaky'));
assert.ok(result.stderr.includes('${CLAUDE_SESSION_ID}'));
} finally {
cleanup(root);
}
});
check('fail: $ARGUMENTS used as a substitution in a bash fence', () => {
const root = createRoot();
try {
writeSkill(
root,
'args',
`${checklistBody('Args')}\n## Code Examples\n\n\`\`\`bash\necho "$ARGUMENTS"\n\`\`\`\n`
);
const result = runValidator(root);
assert.strictEqual(result.status, 1);
assert.ok(result.stderr.includes('args'));
assert.ok(result.stderr.includes('$ARGUMENTS'));
} finally {
cleanup(root);
}
});
check('fail: .agents/skills/foo/SKILL.md has version:', () => {
const root = createRoot();
try {
writeSkill(root, 'foo', checklistBody('Foo'));
writeSkill(root, 'foo', checklistBody('Foo'), {
tree: '.agents/skills',
frontmatter: { version: '1.0.0' },
});
const result = runValidator(root);
assert.strictEqual(result.status, 1);
assert.ok(result.stderr.includes('version'));
assert.ok(result.stderr.includes('.agents/skills/foo/SKILL.md'));
} finally {
cleanup(root);
}
});
check('fail: agents/openai.yaml lists foo but .agents/skills/foo/ is missing', () => {
const root = createRoot();
try {
writeSkill(root, 'foo', checklistBody('Foo'));
writeOpenaiYaml(root, ['foo']);
const result = runValidator(root);
assert.strictEqual(result.status, 1);
assert.ok(result.stderr.includes('agents/openai.yaml lists \'foo\''));
assert.ok(result.stderr.includes('.agents/skills/foo/'));
} finally {
cleanup(root);
}
});
check('warn-only: missing ## When to Activate exits 0 unless --strict-checklist', () => {
const root = createRoot();
try {
writeSkill(
root,
'thin',
'# Thin\n\n## Anti-Patterns\n\n- Missing activation heading\n'
);
const warnResult = runValidator(root);
assert.strictEqual(warnResult.status, 0, warnResult.stderr);
assert.ok(warnResult.stderr.includes('WARN:'));
assert.ok(warnResult.stderr.includes('When to Activate'));
const strictResult = runValidator(root, ['--strict-checklist']);
assert.strictEqual(strictResult.status, 1);
assert.ok(strictResult.stderr.includes('ERROR:'));
} finally {
cleanup(root);
}
});
check('anti-pattern fence still matches when the do-not line wraps', () => {
const root = createRoot();
try {
writeSkill(
root,
'wrapped',
[
'# Wrapped',
'',
'## When to Activate',
'',
'- Reviewing a skill PR',
'',
'## Anti-Patterns',
'',
'Do not put Claude-only substitutions in bash blocks. This fence is the',
'forbidden pattern:',
'',
'```bash',
'echo "${CLAUDE_SESSION_ID}"',
'```',
'',
].join('\n')
);
const result = runValidator(root);
assert.strictEqual(result.status, 0, result.stderr);
assert.ok(!result.stderr.includes('ERROR:'));
} finally {
cleanup(root);
}
});
check('anti-pattern fence that quotes the token as forbidden exits 0', () => {
const root = createRoot();
try {
writeSkill(
root,
'quoted',
[
'# Quoted',
'',
'## When to Activate',
'',
'- Reviewing a skill PR',
'',
'## Anti-Patterns',
'',
'Do not put Claude-only substitutions in bash blocks:',
'',
'```bash',
'echo "${CLAUDE_SESSION_ID}"',
'```',
'',
].join('\n')
);
const result = runValidator(root);
assert.strictEqual(result.status, 0, result.stderr);
assert.ok(!result.stderr.includes('ERROR:'));
} finally {
cleanup(root);
}
});
check('empty skills/ tmp root exits 0', () => {
const root = createRoot();
try {
fs.mkdirSync(path.join(root, 'skills'));
const result = runValidator(root);
assert.strictEqual(result.status, 0, result.stderr);
assert.ok(result.stdout.includes('Validated host compatibility for 0'));
} finally {
cleanup(root);
}
});
check('missing skills directory skips with exit 0', () => {
const root = createRoot();
try {
const result = runValidator(root);
assert.strictEqual(result.status, 0, result.stderr);
assert.ok(result.stdout.includes('skipping'));
} finally {
cleanup(root);
}
});
check('origin on canonical copy is allowed; origin on Codex copy fails', () => {
const root = createRoot();
try {
writeSkill(root, 'foo', checklistBody('Foo'), { frontmatter: { origin: 'ECC' } });
writeSkill(root, 'foo', checklistBody('Foo'), {
tree: '.agents/skills',
frontmatter: { origin: 'ECC' },
});
const result = runValidator(root);
assert.strictEqual(result.status, 1);
assert.ok(result.stderr.includes('origin'));
} finally {
cleanup(root);
}
});
check('Cursor copy name mismatch is an error; extra Cursor copies are not required', () => {
const root = createRoot();
try {
writeSkill(root, 'alpha', checklistBody('Alpha'));
writeSkill(root, 'alpha', checklistBody('Alpha'), {
tree: '.cursor/skills',
name: 'beta',
});
const result = runValidator(root);
assert.strictEqual(result.status, 1);
assert.ok(result.stderr.includes('.cursor/skills/alpha/SKILL.md'));
assert.ok(result.stderr.includes('does not match directory'));
} finally {
cleanup(root);
}
});
check('--inventory prints JSON counts and exits 0 even with errors', () => {
const root = createRoot();
try {
writeSkill(
root,
'leaky',
`${checklistBody('Leaky')}\n## Code Examples\n\n\`\`\`bash\necho "$CLAUDE_SKILL_DIR"\n\`\`\`\n`
);
const result = runValidator(root, ['--inventory']);
assert.strictEqual(result.status, 0, result.stderr);
const counts = JSON.parse(result.stdout.trim());
assert.strictEqual(counts.skillsScanned, 1);
assert.ok(counts.substitutions >= 1);
assert.ok(counts.errors >= 1);
} finally {
cleanup(root);
}
});
check('empty-language shebang fence is scanned; markdown fence is not', () => {
const root = createRoot();
try {
writeSkill(
root,
'mixed',
[
'# Mixed',
'',
'## When to Activate',
'',
'- Reviewing fences',
'',
'## Anti-Patterns',
'',
'- Putting substitutions in bash',
'',
'```markdown',
'Parse $ARGUMENTS in a command file, not in a skill bash block.',
'```',
'',
'```',
'#!/bin/sh',
'echo "$CLAUDE_PLUGIN_ROOT"',
'```',
'',
].join('\n')
);
const result = runValidator(root);
assert.strictEqual(result.status, 1);
assert.ok(result.stderr.includes('$CLAUDE_PLUGIN_ROOT'));
assert.ok(!result.stderr.includes('$ARGUMENTS'));
} finally {
cleanup(root);
}
});
check('Codex copy name mismatch is an error', () => {
const root = createRoot();
try {
writeSkill(root, 'foo', checklistBody('Foo'));
writeSkill(root, 'foo', checklistBody('Foo'), {
tree: '.agents/skills',
name: 'bar',
});
const result = runValidator(root);
assert.strictEqual(result.status, 1);
assert.ok(result.stderr.includes('.agents/skills/foo/SKILL.md'));
} finally {
cleanup(root);
}
});
check('--root without a path exits 1', () => {
const result = spawnSync('node', [validatorPath, '--root'], {
encoding: 'utf8',
cwd: repoRoot,
timeout: 10000,
});
assert.strictEqual(result.status, 1);
assert.ok(result.stderr.includes('--root requires a directory path'));
});
check('canonical name mismatch warns and does not fail', () => {
const root = createRoot();
try {
writeSkill(root, 'foo', checklistBody('Foo'), { name: 'bar' });
const result = runValidator(root);
assert.strictEqual(result.status, 0, result.stderr);
assert.ok(result.stderr.includes('WARN:'));
assert.ok(result.stderr.includes('does not match directory'));
} finally {
cleanup(root);
}
});
check('missing Anti-Patterns heading warns', () => {
const root = createRoot();
try {
writeSkill(
root,
'thin',
'# Thin\n\n## When to Activate\n\n- Reviewing a skill\n'
);
const result = runValidator(root);
assert.strictEqual(result.status, 0);
assert.ok(result.stderr.includes('Anti-Patterns'));
} finally {
cleanup(root);
}
});
check('body over 500 lines warns; over 800 stays warn unless --strict-checklist', () => {
const root = createRoot();
try {
const overFive = `${checklistBody('Long')}\n${'line\n'.repeat(500)}`;
writeSkill(root, 'longish', overFive);
const warnResult = runValidator(root);
assert.strictEqual(warnResult.status, 0, warnResult.stderr);
assert.ok(warnResult.stderr.includes('500'));
cleanup(root);
const root2 = createRoot();
const overEight = `${checklistBody('Huge')}\n${'line\n'.repeat(800)}`;
writeSkill(root2, 'huge', overEight);
const hugeWarn = runValidator(root2);
assert.strictEqual(hugeWarn.status, 0, hugeWarn.stderr);
assert.ok(hugeWarn.stderr.includes('800'));
const hugeStrict = runValidator(root2, ['--strict-checklist']);
assert.strictEqual(hugeStrict.status, 1);
cleanup(root2);
} finally {
if (fs.existsSync(root)) cleanup(root);
}
});
check('bash fence ${1} is treated as a Claude substitution', () => {
const root = createRoot();
try {
writeSkill(
root,
'pos',
`${checklistBody('Pos')}\n## Code Examples\n\n\`\`\`bash\necho "\${1}"\n\`\`\`\n`
);
const result = runValidator(root);
assert.strictEqual(result.status, 1);
assert.ok(result.stderr.includes('$1'));
} finally {
cleanup(root);
}
});
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
@@ -50,6 +50,8 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/catalog.js",
"scripts/ci/scan-supply-chain-iocs.js",
"scripts/ci/supply-chain-advisory-sources.js",
"scripts/ci/validate-skill-host-compat.js",
"scripts/ci/validate-skills.js",
"scripts/consult.js",
"scripts/control-pane.js",
"scripts/dashboard-web.js",
@@ -184,6 +186,7 @@ function main() {
"scripts/catalog.js",
"scripts/ci/scan-supply-chain-iocs.js",
"scripts/ci/supply-chain-advisory-sources.js",
"scripts/ci/validate-skill-host-compat.js",
"scripts/consult.js",
"scripts/control-pane.js",
"scripts/feedback.js",