diff --git a/.agents/skills/skill-host-compat/SKILL.md b/.agents/skills/skill-host-compat/SKILL.md new file mode 100644 index 000000000..3805b4607 --- /dev/null +++ b/.agents/skills/skill-host-compat/SKILL.md @@ -0,0 +1,101 @@ +--- +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`, + `$CLAUDE_PROJECT_DIR`, `$ARGUMENTS`) fail. Codex leaves those tokens + as literal text. In a Codex copy, rewrite `$CLAUDE_PROJECT_DIR` to + `$(pwd)` or an explicit path. +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//`. 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 + +Codex and Cursor projections must resolve Claude-only substitutions in executable blocks. Canonical Claude-scoped examples remain valid. POSIX `$1` and `${1}` parameters are ambiguous and require review rather than failing this gate. This fence is the +forbidden pattern; do not use these unresolved projection examples: + +```bash +echo "$CLAUDE_SESSION_ID" +ls "$CLAUDE_SKILL_DIR" +node "$CLAUDE_PLUGIN_ROOT/scripts/setup.js" $ARGUMENTS +cd "$CLAUDE_PROJECT_DIR" +``` + +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). Rewrite `$CLAUDE_PROJECT_DIR` in Codex copies to `$(pwd)` or an + explicit path. Keep valid Claude-scoped forms in canonical instructions; resolve host variables in the target projection. +- 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 diff --git a/.agents/skills/skill-host-compat/agents/openai.yaml b/.agents/skills/skill-host-compat/agents/openai.yaml new file mode 100644 index 000000000..131be432c --- /dev/null +++ b/.agents/skills/skill-host-compat/agents/openai.yaml @@ -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 diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 09fc9f797..507a49d07 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "ecc", "source": "./", - "description": "Harness-native ECC operator layer - 71 agents, 302 skills, 95 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", + "description": "Harness-native ECC operator layer - 71 agents, 303 skills, 96 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", "version": "2.2.3", "author": { "name": "Affaan Mustafa", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 351ac68f1..31e57105c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ecc", "version": "2.2.3", - "description": "Harness-native ECC plugin for engineering teams - 71 agents, 302 skills, 95 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", + "description": "Harness-native ECC plugin for engineering teams - 71 agents, 303 skills, 96 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", "author": { "name": "Affaan Mustafa", "url": "https://x.com/affaanmustafa" diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md index cb1112c84..2a13eaf9b 100644 --- a/.gemini/GEMINI.md +++ b/.gemini/GEMINI.md @@ -4,7 +4,7 @@ This file provides Gemini CLI with the baseline ECC workflow, review standards, ## Overview -ECC is a cross-harness coding system with 71 specialized agents, 302 skills, and 95 commands. +ECC is a cross-harness coding system with 71 specialized agents, 303 skills, and 96 commands. Gemini support is currently focused on a strong project-local instruction layer via `.gemini/GEMINI.md`, plus the shared MCP catalog and package-manager setup assets shipped by the installer. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 302a21651..2f1f889ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/reusable-validate.yml b/.github/workflows/reusable-validate.yml index 66e295e13..7ae56f044 100644 --- a/.github/workflows/reusable-validate.yml +++ b/.github/workflows/reusable-validate.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index ab61e04c4..077ba74dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # ECC — Agent Instructions -This is a **production-ready AI coding plugin** providing 71 specialized agents, 302 skills, 95 commands, and automated hook workflows for software development. +This is a **production-ready AI coding plugin** providing 71 specialized agents, 303 skills, 96 commands, and automated hook workflows for software development. **Version:** 2.2.3 @@ -202,8 +202,8 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat ``` agents/ — 71 specialized subagents -skills/ — 302 workflow skills and domain knowledge -commands/ — 95 slash commands +skills/ — 303 workflow skills and domain knowledge +commands/ — 96 slash commands hooks/ — Trigger-based automations rules/ — Always-follow guidelines (common + per-language) scripts/ — Cross-platform Node.js utilities diff --git a/COMMANDS-QUICK-REF.md b/COMMANDS-QUICK-REF.md index f52b1e3d3..b5eefc2ce 100644 --- a/COMMANDS-QUICK-REF.md +++ b/COMMANDS-QUICK-REF.md @@ -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 | --- diff --git a/README.md b/README.md index 507b2fb3b..32b5a1b2b 100644 --- a/README.md +++ b/README.md @@ -140,13 +140,13 @@ Instead of rebuilding that process in every prompt, you install it once and make ECC is MIT-licensed open source. It works best with Claude Code today, has native Codex and Qoder plugin paths, and provides capability-limited adapters for Cursor, OpenCode, Gemini, Zed, GitHub Copilot, Antigravity, Qwen, and other harnesses. See the [support status matrix](#platform-support) before assuming feature parity. -Access to 71 agents, 302 skills, and 95 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work. +Access to 71 agents, 303 skills, and 96 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work. | Included | Count | What it gives you | | ---------------- | ----------: | ------------------------------------------------------------------------------------ | | Agents | 71 agents | Planning, review, build repair, security, architecture, and domain work | -| Skills | 302 skills | TDD, research, security, docs, frontend, data, ML, operations, and more | -| Commands | 95 commands | Convenient entry points while ECC moves to a skills-first surface | +| Skills | 303 skills | TDD, research, security, docs, frontend, data, ML, operations, and more | +| Commands | 96 commands | Convenient entry points while ECC moves to a skills-first surface | | Hooks and memory | Runtime | Enforcement, session summaries, continuous learning, instincts, and context controls | | Rules | Selective | Always-loaded standards you choose by language or project | | AgentShield | Included | Scanning for prompts, hooks, MCP config, permissions, secrets, and agent files | diff --git a/README.zh-CN.md b/README.zh-CN.md index 321f17437..a3bfb3ef5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -200,7 +200,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**完成!** 你现在可以使用 71 个代理、302 个技能和 95 个命令。 +**完成!** 你现在可以使用 71 个代理、303 个技能和 96 个命令。 ### multi-* 命令需要额外配置 diff --git a/SOUL.md b/SOUL.md index cdc9007ca..d477ce493 100644 --- a/SOUL.md +++ b/SOUL.md @@ -1,7 +1,7 @@ # Soul ## Core Identity -ECC is a production-ready AI coding plugin with 71 specialized agents, 302 skills, 95 commands, and automated hook workflows for software development. +ECC is a production-ready AI coding plugin with 71 specialized agents, 303 skills, 96 commands, and automated hook workflows for software development. ## Core Principles 1. **Agent-First** — route work to the right specialist as early as possible. diff --git a/agent.yaml b/agent.yaml index 79aab861f..3e8cc2826 100644 --- a/agent.yaml +++ b/agent.yaml @@ -147,6 +147,7 @@ skills: - security-review - security-scan - skill-comply + - skill-host-compat - skill-stocktake - springboot-patterns - springboot-security @@ -261,6 +262,7 @@ commands: - setup-pm - skill-create - skill-health + - skill-host-compat - test-coverage - token-card - update-codemaps diff --git a/commands/skill-host-compat.md b/commands/skill-host-compat.md new file mode 100644 index 000000000..e3abc77f4 --- /dev/null +++ b/commands/skill-host-compat.md @@ -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/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: canonical checklist plus host-specific Codex/Cursor projections. + * POSIX positional parameters ($1, ${1}) are ambiguous and never fail this gate. 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: '${CLAUDE_PROJECT_DIR}', regex: /\$\{CLAUDE_PROJECT_DIR\}/g }, + { token: '$CLAUDE_PROJECT_DIR', regex: /\$CLAUDE_PROJECT_DIR\b/g }, + { token: '$ARGUMENTS', regex: /\$ARGUMENTS\b|\$\{ARGUMENTS\}/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) { + return CLAUDE_SUBSTITUTION_PATTERNS.filter(pattern => { + pattern.regex.lastIndex = 0; + return pattern.regex.test(fenceContent); + }).map(pattern => pattern.token); +} + +function inspectProjectionSubstitutions(skillName, content, treeLabel, findings) { + const { body } = stripFrontmatter(content); + for (const fence of findExecutableFences(body)) { + if (precedingAllowsToken(body, fence.start)) continue; + for (const token of findSubstitutionTokens(fence.content)) { + pushFinding(findings, { severity: 'error', code: 'substitution', skill: skillName, + token, message: `${treeLabel}/${substitutionMessage(skillName, fence.lang, token)}` }); + } + } +} + +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 substitutionMessage(skillName, fenceLang, token) { + const base = `${relSkillMd(skillName)} - ${fenceLang} fence contains Claude-only substitution ${token}`; + if (token === '${CLAUDE_PROJECT_DIR}' || token === '$CLAUDE_PROJECT_DIR') { + return `${base}; rewrite to $(pwd) or an explicit path`; + } + return base; +} + +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)`, + }); + } + + +} + +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); + inspectProjectionSubstitutions(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); + inspectProjectionSubstitutions(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, +}; diff --git a/scripts/ci/validate-skills.js b/scripts/ci/validate-skills.js index 47334687f..0bcc4df48 100644 --- a/scripts/ci/validate-skills.js +++ b/scripts/ci/validate-skills.js @@ -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); + } } diff --git a/scripts/lib/install/codex-user-config.js b/scripts/lib/install/codex-user-config.js index 5b7aa612c..90b6c8c84 100644 --- a/scripts/lib/install/codex-user-config.js +++ b/scripts/lib/install/codex-user-config.js @@ -6,7 +6,7 @@ const path = require('path'); const { assertWithinTrustedRoot } = require('../path-safety'); function isCodexUserConfig(plan, operation) { - if (plan.adapter.id !== 'codex-home' || operation.kind !== 'copy-file') { + if (!plan.adapter || plan.adapter.id !== 'codex-home' || operation.kind !== 'copy-file') { return false; } const relativePath = path.relative(plan.targetRoot, operation.destinationPath); diff --git a/skills/skill-host-compat/SKILL.md b/skills/skill-host-compat/SKILL.md new file mode 100644 index 000000000..220e8b311 --- /dev/null +++ b/skills/skill-host-compat/SKILL.md @@ -0,0 +1,102 @@ +--- +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 + in Codex/Cursor projections (`${CLAUDE_SESSION_ID}`, `${CLAUDE_SKILL_DIR}`, `${CLAUDE_PLUGIN_ROOT}`, + `${CLAUDE_PROJECT_DIR}`, `$ARGUMENTS`) fail. Codex leaves those tokens + as literal text. In a Codex copy, rewrite `${CLAUDE_PROJECT_DIR}` to + `$(pwd)` or an explicit path. +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//`. 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 + +Codex and Cursor projections must resolve Claude-only substitutions in executable blocks. Canonical Claude-scoped examples remain valid. POSIX `$1` and `${1}` parameters are ambiguous and require review rather than failing this gate. This fence is the +forbidden pattern: + +```bash +echo "${CLAUDE_SESSION_ID}" +ls "${CLAUDE_SKILL_DIR}" +node "${CLAUDE_PLUGIN_ROOT}/scripts/setup.js" $ARGUMENTS +cd "${CLAUDE_PROJECT_DIR}" +``` + +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). Rewrite `${CLAUDE_PROJECT_DIR}` in Codex copies to `$(pwd)` or an + explicit path. Keep valid Claude-scoped forms in canonical instructions; resolve host variables in the target projection. +- 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 diff --git a/tests/ci/validate-skill-host-compat.test.js b/tests/ci/validate-skill-host-compat.test.js new file mode 100644 index 000000000..09ba27706 --- /dev/null +++ b/tests/ci/validate-skill-host-compat.test.js @@ -0,0 +1,664 @@ +#!/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` + ); + writeSkill(root, 'leaky', fs.readFileSync(path.join(root, 'skills', 'leaky', 'SKILL.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''), { tree: '.agents/skills' }); + 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: bash fence contains ${CLAUDE_PROJECT_DIR}', () => { + const root = createRoot(); + try { + writeSkill( + root, + 'proj', + `${checklistBody('Proj')}\n## Code Examples\n\n\`\`\`bash\ncd "\${CLAUDE_PROJECT_DIR}"\n\`\`\`\n` + ); + writeSkill(root, 'proj', fs.readFileSync(path.join(root, 'skills', 'proj', 'SKILL.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''), { tree: '.agents/skills' }); + const result = runValidator(root); + assert.strictEqual(result.status, 1); + assert.ok(result.stderr.includes('ERROR:')); + assert.ok(result.stderr.includes('proj')); + assert.ok(result.stderr.includes('${CLAUDE_PROJECT_DIR}')); + assert.ok(result.stderr.includes('rewrite to $(pwd) or an explicit path')); + } finally { + cleanup(root); + } +}); + +check('fail: $CLAUDE_PROJECT_DIR used as a substitution in a bash fence', () => { + const root = createRoot(); + try { + writeSkill( + root, + 'proj', + `${checklistBody('Proj')}\n## Code Examples\n\n\`\`\`sh\ncd "$CLAUDE_PROJECT_DIR"\n\`\`\`\n` + ); + writeSkill(root, 'proj', fs.readFileSync(path.join(root, 'skills', 'proj', 'SKILL.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''), { tree: '.agents/skills' }); + const result = runValidator(root); + assert.strictEqual(result.status, 1); + assert.ok(result.stderr.includes('$CLAUDE_PROJECT_DIR')); + assert.ok(result.stderr.includes('rewrite to $(pwd) or an explicit path')); + } finally { + cleanup(root); + } +}); + +check('bash fence $(pwd) is accepted in place of CLAUDE_PROJECT_DIR', () => { + const root = createRoot(); + try { + writeSkill( + root, + 'proj', + `${checklistBody('Proj')}\n## Code Examples\n\n\`\`\`bash\ncd "$(pwd)"\n\`\`\`\n` + ); + writeSkill(root, 'proj', fs.readFileSync(path.join(root, 'skills', 'proj', 'SKILL.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''), { tree: '.agents/skills' }); + const result = runValidator(root); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(!result.stderr.includes('ERROR:')); + assert.ok(!result.stderr.includes('CLAUDE_PROJECT_DIR')); + } finally { + cleanup(root); + } +}); + +check('bash fence variables that only contain CLAUDE_PROJECT_DIR are accepted', () => { + const root = createRoot(); + try { + writeSkill( + root, + 'proj', + `${checklistBody('Proj')}\n## Code Examples\n\n\`\`\`bash\ncd "$MY_CLAUDE_PROJECT_DIR"\ncd "\${MY_CLAUDE_PROJECT_DIR}"\nls "$CLAUDE_PROJECT_DIR_TMP"\nls "\${CLAUDE_PROJECT_DIR_TMP}"\n\`\`\`\n` + ); + writeSkill(root, 'proj', fs.readFileSync(path.join(root, 'skills', 'proj', 'SKILL.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''), { tree: '.agents/skills' }); + const result = runValidator(root); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(!result.stderr.includes('ERROR:')); + } finally { + cleanup(root); + } +}); + +check('prose mention of CLAUDE_PROJECT_DIR does not fail', () => { + const root = createRoot(); + try { + writeSkill( + root, + 'prose', + `${checklistBody('Prose')}\nThe project root is \`CLAUDE_PROJECT_DIR\`, then cwd.\n` + ); + writeSkill(root, 'prose', fs.readFileSync(path.join(root, 'skills', 'prose', 'SKILL.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''), { tree: '.agents/skills' }); + const result = runValidator(root); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(!result.stderr.includes('ERROR:')); + } 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` + ); + writeSkill(root, 'args', fs.readFileSync(path.join(root, 'skills', 'args', 'SKILL.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''), { tree: '.agents/skills' }); + 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` + ); + writeSkill(root, 'leaky', fs.readFileSync(path.join(root, 'skills', 'leaky', 'SKILL.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''), { tree: '.agents/skills' }); + 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') + ); + writeSkill(root, 'mixed', fs.readFileSync(path.join(root, 'skills', 'mixed', 'SKILL.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''), { tree: '.agents/skills' }); + 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 accepted as an ambiguous POSIX parameter', () => { + const root = createRoot(); + try { + writeSkill( + root, + 'pos', + `${checklistBody('Pos')}\n## Code Examples\n\n\`\`\`bash\necho "\${1}"\n\`\`\`\n` + ); + writeSkill(root, 'pos', fs.readFileSync(path.join(root, 'skills', 'pos', 'SKILL.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''), { tree: '.agents/skills' }); + const result = runValidator(root); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(!result.stderr.includes('ERROR:')); + } finally { + cleanup(root); + } +}); + +check('POSIX VAR=$1 assignment is not a Claude substitution', () => { + const root = createRoot(); + try { + writeSkill( + root, + 'posix', + `${checklistBody('Posix')}\n## Code Examples\n\n\`\`\`bash\nmove_dir() {\n SOURCE=$1\n DEST=$2\n}\n\`\`\`\n` + ); + writeSkill(root, 'posix', fs.readFileSync(path.join(root, 'skills', 'posix', 'SKILL.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''), { tree: '.agents/skills' }); + const result = runValidator(root); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(!result.stderr.includes('ERROR:')); + } finally { + cleanup(root); + } +}); + +check('Perl/awk $1 numeric compare is not a Claude substitution', () => { + const root = createRoot(); + try { + writeSkill( + root, + 'cover', + `${checklistBody('Cover')}\n## Code Examples\n\n\`\`\`bash\nperl -ne 'if (/Total.*?(\\d+)/) { exit 1 if $1 < 80 }'\n\`\`\`\n` + ); + writeSkill(root, 'cover', fs.readFileSync(path.join(root, 'skills', 'cover', 'SKILL.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''), { tree: '.agents/skills' }); + const result = runValidator(root); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(!result.stderr.includes('ERROR:')); + } finally { + cleanup(root); + } +}); + +check('echo $1 in a bash fence is accepted as an ambiguous POSIX parameter', () => { + const root = createRoot(); + try { + writeSkill( + root, + 'echoone', + `${checklistBody('Echo')}\n## Code Examples\n\n\`\`\`bash\necho $1\n\`\`\`\n` + ); + writeSkill(root, 'echoone', fs.readFileSync(path.join(root, 'skills', 'echoone', 'SKILL.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''), { tree: '.agents/skills' }); + const result = runValidator(root); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(!result.stderr.includes('ERROR:')); + } finally { + cleanup(root); + } +}); + +check('canonical Claude examples remain available while unresolved Codex projections fail', () => { + const root = createRoot(); + try { + const body = `${checklistBody('Scoped')}\n## Workflow\n\n\`\`\`bash\necho "$CLAUDE_SKILL_DIR"\n\`\`\`\n`; + writeSkill(root, 'scoped', body); + assert.strictEqual(runValidator(root).status, 0); + writeSkill(root, 'scoped', body, { tree: '.agents/skills' }); + const result = runValidator(root); + assert.strictEqual(result.status, 1); + assert.ok(result.stderr.includes('.agents/skills/scoped/SKILL.md')); + } finally { cleanup(root); } +}); + +check('POSIX function arguments $1 and ${1} pass in the Codex projection', () => { + const root = createRoot(); + try { + const body = `${checklistBody('Positional')}\n## Workflow\n\n\`\`\`sh\nprint_arg() { printf '%s\\n' "$1" "\${1}"; }\nprint_arg hello\n\`\`\`\n`; + writeSkill(root, 'positional', body); + writeSkill(root, 'positional', body, { tree: '.agents/skills' }); + const result = runValidator(root); + assert.strictEqual(result.status, 0, result.stderr); + } finally { cleanup(root); } +}); + +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/scripts/codex-hooks.test.js b/tests/scripts/codex-hooks.test.js index 50d0cf5f0..1055dd0b2 100644 --- a/tests/scripts/codex-hooks.test.js +++ b/tests/scripts/codex-hooks.test.js @@ -453,7 +453,7 @@ else failed++; // A case-folded spelling, because macOS resolves `$venv/bin/python` to a committed // `Python` while git matches index pathspecs case-sensitively. Skipped where the // filesystem is case-sensitive and the two names cannot collide. -if (fs.existsSync(__filename.toUpperCase()) || fs.existsSync(__filename.toLowerCase())) { +if (fs.existsSync(__filename.toUpperCase()) && fs.existsSync(__filename.toLowerCase())) { if ( test('pre-push refuses a tracked interpreter committed under a folded case', () => { const { result, calls } = runHermeticPythonPrePush({ diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index ce9afaae8..5a810ef04 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -71,6 +71,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/profile.js", "scripts/control-pane.js", @@ -227,6 +229,8 @@ 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/ci/validate-skills.js", "scripts/consult.js", "scripts/profile.js", "scripts/lib/context-profiles.js", @@ -303,6 +307,7 @@ function main() { "schemas/memory.schema.json", "skills/backend-patterns/SKILL.md", "skills/skill-comply/SKILL.md", + "skills/skill-host-compat/SKILL.md", "skills/unified-memory/SKILL.md", "scripts/agent-convert.js", "scripts/lib/agent-ir.js",