From 5ed453c74cc74bcd80f7505e3fd3c9c197d063ba Mon Sep 17 00:00:00 2001 From: sx4im Date: Wed, 7 Oct 2026 21:07:44 +0500 Subject: [PATCH 1/4] feat(cli): accept .cursorrules and .mdc files as markdown skills Co-Authored-By: Claude Code --- README.md | 3 +-- packages/cli/src/normalize.ts | 25 +++++++++++++++++-------- packages/cli/src/types.ts | 2 +- packages/cli/src/ui/picker.ts | 19 +++++++++++++------ packages/cli/test/cli.test.ts | 23 ++++++++++++++++++++++- packages/cli/test/normalize.test.ts | 16 ++++++++++++++++ 6 files changed, 70 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 03bffb1..ea1764c 100644 --- a/README.md +++ b/README.md @@ -271,8 +271,7 @@ skillcheck rot [--results dir] [--output report.json] # detect skillcheck --version ``` -Accepted inputs: any Markdown (`.md`) file — `SKILL.md`, `AGENTS.md`, `CLAUDE.md`, -or any other `.md` — or a folder containing one. Headings are used as the declared domain +Accepted inputs: any Markdown (`.md`, `.mdc`) or `.cursorrules` file (`SKILL.md`, `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `.cursor/rules/*.mdc`, or any other `.md`), or a folder containing one. Headings are used as the declared domain when front matter is absent; if a heading is itself an instruction, pass `--domain ""` for a blind run. `--tasks` is capped at 50 and `--trials` at 10; mistyped options are rejected rather than silently ignored. diff --git a/packages/cli/src/normalize.ts b/packages/cli/src/normalize.ts index 9060b7f..6d3508b 100644 --- a/packages/cli/src/normalize.ts +++ b/packages/cli/src/normalize.ts @@ -16,8 +16,12 @@ function formatForFile(filePath: string): SkillFormat | undefined { if (known) { return known.format; } + const ext = path.extname(basename).toLowerCase(); + if (ext === '.mdc') { + return 'mdc'; + } // Any Markdown file is accepted as a generic skill document. - if (path.extname(basename).toLowerCase() === '.md') { + if (ext === '.md') { return 'markdown'; } return undefined; @@ -40,11 +44,16 @@ async function firstMarkdownInDir(dirPath: string): Promise<{ filePath: string; const entries = await readdir(dirPath, { withFileTypes: true }); const markdown = entries - .filter((entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === '.md') + .filter((entry) => { + if (!entry.isFile()) return false; + const ext = path.extname(entry.name).toLowerCase(); + return ext === '.md' || ext === '.mdc'; + }) .map((entry) => entry.name) .sort(); if (markdown[0]) { - return { filePath: path.join(dirPath, markdown[0]), format: 'markdown' }; + const filePath = path.join(dirPath, markdown[0]); + return { filePath, format: formatForFile(filePath) ?? 'markdown' }; } return undefined; } @@ -54,14 +63,14 @@ async function resolveSkillFile(inputPath: string): Promise<{ filePath: string; if (stats.isDirectory()) { const found = await firstMarkdownInDir(inputPath); if (!found) { - throw new Error(`No .md file found in ${inputPath}. Skillcheck only analyzes Markdown (.md) skill files.`); + throw new Error(`No .md file found in ${inputPath}. Skillcheck only analyzes Markdown (.md, .mdc) and .cursorrules skill files.`); } return found; } const format = formatForFile(inputPath); if (!format) { - throw new Error(`Skillcheck only analyzes Markdown (.md) files. "${path.basename(inputPath)}" is not a .md file.`); + throw new Error(`Skillcheck only analyzes Markdown (.md, .mdc) and .cursorrules files. "${path.basename(inputPath)}" is not supported.`); } return { filePath: inputPath, format }; } @@ -142,7 +151,7 @@ function isGenericHeading(heading: string, fileName?: string): boolean { } if (fileName) { const fileNorm = fileName.trim().toLowerCase(); - const fileBase = fileNorm.replace(/\.md$/, ''); + const fileBase = fileNorm.replace(/\.(md|mdc)$/, ''); if (norm === fileNorm || norm === fileBase) { return true; } @@ -210,10 +219,10 @@ async function listAssets(skillFilePath: string): Promise { function nameFromPath(filePath: string, format: SkillFormat): string { const base = path.basename(filePath); - if (format === 'markdown') { + if (format === 'markdown' || format === 'mdc') { // A user-named file (e.g. frontend-design.md) is more meaningful than its // parent folder — turn "frontend-design.md" into "frontend design". - const stem = base.replace(/\.md$/i, '').replace(/[-_]+/g, ' ').trim(); + const stem = base.replace(/\.(md|mdc)$/i, '').replace(/[-_]+/g, ' ').trim(); return stem || path.basename(path.dirname(filePath)) || base; } // Conventional files (SKILL.md, AGENTS.md, …) take their identity from the folder. diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index f4e5af6..fa21a78 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,4 +1,4 @@ -export type SkillFormat = 'SKILL.md' | 'AGENTS.md' | '.cursorrules' | 'CLAUDE.md' | 'markdown'; +export type SkillFormat = 'SKILL.md' | 'AGENTS.md' | '.cursorrules' | 'CLAUDE.md' | 'markdown' | 'mdc'; export type CriterionType = 'rubric' | 'deterministic'; // Task-generation difficulty: harder levels ask the generator for edge diff --git a/packages/cli/src/ui/picker.ts b/packages/cli/src/ui/picker.ts index 87d7cfe..f8273f9 100644 --- a/packages/cli/src/ui/picker.ts +++ b/packages/cli/src/ui/picker.ts @@ -5,8 +5,15 @@ import readline from 'node:readline'; import { SYM, padDisplay, paint, truncateDisplay } from './theme.js'; import { bannerLines, printBanner } from './banner.js'; -const CONVENTIONAL_SKILL_FILES = ['SKILL.md', 'AGENTS.md', 'CLAUDE.md']; -const isMarkdownFile = (filePath: string): boolean => path.extname(filePath).toLowerCase() === '.md'; +const CONVENTIONAL_SKILL_FILES = ['SKILL.md', 'AGENTS.md', 'CLAUDE.md', '.cursorrules']; +const isMarkdownFile = (filePath: string): boolean => { + const base = path.basename(filePath); + if (base === '.cursorrules') { + return true; + } + const ext = path.extname(filePath).toLowerCase(); + return ext === '.md' || ext === '.mdc'; +}; export class CancelledError extends Error { readonly exitCode = 130; @@ -31,7 +38,7 @@ export interface Keypress { } export function supportedSkillFilesText(): string { - return `a Markdown (.md) file (e.g. ${CONVENTIONAL_SKILL_FILES.join(', ')}), or a folder containing one`; + return `a Markdown (.md, .mdc) or .cursorrules file (e.g. ${CONVENTIONAL_SKILL_FILES.join(', ')}), or a folder containing one`; } export async function directoryHasMarkdown(dirPath: string): Promise { @@ -51,13 +58,13 @@ export async function validateSkillInput(inputPath: string): Promise { if (await directoryHasMarkdown(resolved)) { return inputPath; } - throw new Error(`That folder has no .md file. Skillcheck only checks Markdown (.md) files — open a folder that contains one, or pick a .md file directly.`); + throw new Error(`That folder has no supported skill file (.md, .mdc, .cursorrules). Skillcheck only checks Markdown (.md, .mdc) and .cursorrules files — open a folder that contains one, or pick a file directly.`); } if (stats.isFile()) { if (isMarkdownFile(resolved)) { return inputPath; } - throw new Error(`Skillcheck only checks Markdown (.md) files. "${path.basename(resolved)}" is not a .md file.`); + throw new Error(`Skillcheck only checks Markdown (.md, .mdc) and .cursorrules files. "${path.basename(resolved)}" is not a supported file.`); } } catch (error) { const code = (error as NodeJS.ErrnoException).code; @@ -328,7 +335,7 @@ export async function selectSkillPath(startDir = process.cwd()): Promise chosen = entry.fullPath; break; } - message = 'We only check Markdown (.md) files. Open a folder to find one, or pick a .md file.'; + message = 'We only check Markdown (.md, .mdc) and .cursorrules files. Open a folder to find one, or pick a supported file.'; } }, { altScreen: true }); diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 8a1abe3..0662b40 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { evalResultFixture } from './eval-result-fixture.js'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -198,6 +198,27 @@ describe('friendly CLI check command', () => { await expect(validateSkillInput(md)).resolves.toBe(md); }); + it('accepts .cursorrules and .cursor/rules/*.mdc files', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'skillcheck-validate-')); + const cursorrules = path.join(dir, '.cursorrules'); + await writeFile(cursorrules, '# Cursor rules\n\nAlways write tests.\n'); + await expect(validateSkillInput(cursorrules)).resolves.toBe(cursorrules); + + const rulesDir = path.join(dir, '.cursor', 'rules'); + await mkdir(rulesDir, { recursive: true }); + const mdc = path.join(rulesDir, 'api.mdc'); + await writeFile(mdc, '---\ndescription: API rules\n---\n# API rules\n\nFollow REST.\n'); + await expect(validateSkillInput(mdc)).resolves.toBe(mdc); + + const cursorrulesDir = await mkdtemp(path.join(tmpdir(), 'skillcheck-dir-cursorrules-')); + await writeFile(path.join(cursorrulesDir, '.cursorrules'), '# Rules\n'); + await expect(validateSkillInput(cursorrulesDir)).resolves.toBe(cursorrulesDir); + + const mdcDir = await mkdtemp(path.join(tmpdir(), 'skillcheck-dir-mdc-')); + await writeFile(path.join(mdcDir, 'rule.mdc'), '# MDC Rule\n'); + await expect(validateSkillInput(mdcDir)).resolves.toBe(mdcDir); + }); + it('rejects files that are not Markdown', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'skillcheck-validate-')); const txt = path.join(dir, 'notes.txt'); diff --git a/packages/cli/test/normalize.test.ts b/packages/cli/test/normalize.test.ts index c659ddb..6c3a566 100644 --- a/packages/cli/test/normalize.test.ts +++ b/packages/cli/test/normalize.test.ts @@ -46,6 +46,22 @@ describe('normalizeSkill', () => { expect(skill.domain).toBe('React accessibility reviews'); }); + it('normalizes .cursor/rules/*.mdc by path and folder', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'skillcheck-normalize-')); + const rulesDir = path.join(dir, '.cursor', 'rules'); + await mkdir(rulesDir, { recursive: true }); + const file = path.join(rulesDir, 'api.mdc'); + await writeFile(file, '---\ndescription: API documentation editing\n---\n# API Guide\n\nUse precise docs language.\n'); + + const skill = await normalizeSkill(file); + expect(skill.format).toBe('mdc'); + expect(skill.name).toBe('API Guide'); + expect(skill.domain).toBe('API documentation editing'); + + const dirSkill = await normalizeSkill(rulesDir); + expect(dirSkill.format).toBe('mdc'); + }); + it('normalizes CLAUDE.md for awesome-claude-md corpus entries', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'skillcheck-normalize-')); await writeFile(path.join(dir, 'CLAUDE.md'), '# Next.js Rules\n\ndescription: Next.js app development\n'); From e226e88667f7d53240326b8e1b7d3982657394af Mon Sep 17 00:00:00 2001 From: sx4im Date: Wed, 7 Oct 2026 21:12:35 +0500 Subject: [PATCH 2/4] fix(cli): escape user mentions and markdown injection in reports Escape mentions, links, HTML tags, code spans, pipes, and newlines in skill names and task prompts when generating markdown reports. Co-Authored-By: Claude Code --- packages/cli/src/ui/markdown.ts | 20 ++++++++++++---- packages/cli/test/ui-print.test.ts | 37 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/markdown.ts b/packages/cli/src/ui/markdown.ts index a67bb4f..ae4c4a8 100644 --- a/packages/cli/src/ui/markdown.ts +++ b/packages/cli/src/ui/markdown.ts @@ -19,9 +19,19 @@ function excerpt(prompt: string, max = 80): string { return single.length > max ? `${single.slice(0, max - 1).trimEnd()}…` : single; } -// A literal `|` would split a GFM table cell, so escape it. -function escapeCell(text: string): string { - return text.replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); +// Escapes untrusted text so it cannot ping users, break out of headings, +// or inject links, HTML, code spans, or table columns in GitHub-Flavored Markdown. +export function escapeMarkdown(text: string): string { + return text + .replace(/\r?\n/g, ' ') + .replace(/\\/g, '\\\\') + .replace(/@/g, '@​') + .replace(/\[/g, '\\[') + .replace(/\]/g, '\\]') + .replace(//g, '>') + .replace(/`/g, '\\`') + .replace(/\|/g, '\\|'); } /** @@ -33,7 +43,7 @@ export function formatMarkdownReport(result: EvalResult): string { const [ciLow, ciHigh] = outcome.ci_pp; const lines: string[] = [ - `# Skillcheck Evaluation: ${skill.name}`, + `# Skillcheck Evaluation: ${escapeMarkdown(skill.name)}`, '', `## ${VERDICT_BADGE[outcome.verdict]}`, '' @@ -71,7 +81,7 @@ export function formatMarkdownReport(result: EvalResult): string { for (const task of result.tasks) { const delta = (task.with_skill_pass_rate - task.no_skill_pass_rate) * 100; lines.push( - `| ${escapeCell(task.id)} | ${escapeCell(excerpt(task.prompt))} | ${pct(task.with_skill_pass_rate)} | ${pct(task.no_skill_pass_rate)} | ${pp(delta)} |` + `| ${escapeMarkdown(task.id)} | ${escapeMarkdown(excerpt(task.prompt))} | ${pct(task.with_skill_pass_rate)} | ${pct(task.no_skill_pass_rate)} | ${pp(delta)} |` ); } lines.push(''); diff --git a/packages/cli/test/ui-print.test.ts b/packages/cli/test/ui-print.test.ts index f908a3f..d92a58e 100644 --- a/packages/cli/test/ui-print.test.ts +++ b/packages/cli/test/ui-print.test.ts @@ -239,4 +239,41 @@ describe('formatMarkdownReport', () => { expect(report).not.toContain(':warning:'); expect(report).not.toContain('Small sample'); }); + + it('escapes user mentions, links, HTML tags, backticks, pipes, and newlines in skill name', () => { + const report = formatMarkdownReport(evalResultFixture({ + skill: { + name: 'Docs @octocat [claim your prize](http://evil.example)