diff --git a/.github/workflows/validate-skills.yml b/.github/workflows/validate-skills.yml index c75a668..758d436 100644 --- a/.github/workflows/validate-skills.yml +++ b/.github/workflows/validate-skills.yml @@ -63,7 +63,10 @@ jobs: fi # Run skill-specific validator if exists - if [ -f "${skill_dir}scripts/validate-skill.js" ]; then + if [ -f "${skill_dir}scripts/validate-skill.cjs" ]; then + echo " šŸ”§ Running custom validator..." + node "${skill_dir}scripts/validate-skill.cjs" "${skill_dir}SKILL.md" || exit 1 + elif [ -f "${skill_dir}scripts/validate-skill.js" ]; then echo " šŸ”§ Running custom validator..." node "${skill_dir}scripts/validate-skill.js" "${skill_dir}SKILL.md" || exit 1 fi diff --git a/README.md b/README.md index 2a1f77e..90a2a1a 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,25 @@ Error prevention and best practices enforcement for AI agent-assisted coding. - Model configuration recommendations (Claude 3.5+, GPT-4+, extended thinking) - 60KB+ of reference documentation +### [ascii-renderer](./skills/ascii-renderer) + +Generate ASCII art from images or text using shape vector rendering. + +**Use when:** + +- Creating ASCII text banners for terminals, READMEs, or CLI tools +- Converting images to ASCII art with high-quality edge detection +- Building terminal splash screens or headers +- Generating retro/terminal aesthetic text + +**Features:** + +- Text-to-ASCII with customizable fonts (Arial Black, Impact, etc.) +- Image-to-ASCII conversion with sharp edge detection +- Shape vector algorithm for superior quality vs traditional brightness mapping +- Adjustable contrast, size, and invert options +- Built-in demo mode + --- ## Installation @@ -108,7 +127,7 @@ description: What it does and when to use it 1. **Keep SKILL.md under 500 lines** - Move detailed docs to `references/` 2. **Write for agents** - Clear, actionable instructions 3. **Include examples** - Show both āŒ BAD and āœ… GOOD patterns -4. **Test your skill** - Run `node scripts/validate-skill.js` if available +4. **Test your skill** - Run `node scripts/validate-skill.cjs` if available --- diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..c5a82a7 --- /dev/null +++ b/biome.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "useIterableCallbackReturn": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..a20e32d --- /dev/null +++ b/bun.lock @@ -0,0 +1,93 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "skills", + "dependencies": { + "sharp": "^0.34.5", + }, + "devDependencies": { + "@biomejs/biome": "^2.3.14", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.3.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.14", "@biomejs/cli-darwin-x64": "2.3.14", "@biomejs/cli-linux-arm64": "2.3.14", "@biomejs/cli-linux-arm64-musl": "2.3.14", "@biomejs/cli-linux-x64": "2.3.14", "@biomejs/cli-linux-x64-musl": "2.3.14", "@biomejs/cli-win32-arm64": "2.3.14", "@biomejs/cli-win32-x64": "2.3.14" }, "bin": { "biome": "bin/biome" } }, "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..86a5fb3 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "skills", + "type": "module", + "private": true, + "scripts": { + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write ." + }, + "dependencies": { + "sharp": "^0.34.5" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.14" + } +} diff --git a/skills/ascii-renderer/SKILL.md b/skills/ascii-renderer/SKILL.md new file mode 100644 index 0000000..ea99a34 --- /dev/null +++ b/skills/ascii-renderer/SKILL.md @@ -0,0 +1,139 @@ +--- +name: ascii-renderer +description: Generate ASCII art from images or text using shape vector rendering. Creates sharp-edged ASCII art banners, converts photos to terminal art, and renders text with customizable fonts. +--- + +# ASCII Art Renderer + +Convert images or text to ASCII art using the **shape vector algorithm** - a technique that produces sharp edges instead of blurry pixelated results. + +Based on [Alex Harri's ASCII rendering research](https://alexharri.com/blog/ascii-rendering). + +## When to Use This Skill + +Activate this skill when: +- User wants to create ASCII text banners (e.g., "make ASCII art saying HELLO") +- Converting images to ASCII art for terminals or READMEs +- Generating retro/terminal aesthetic text +- Building CLI tool splash screens or headers + +## When NOT to Use This Skill + +Skip this skill when: +- User needs simple block letters (figlet/toilet may be simpler) +- Color ASCII art is required (this produces monochrome output) +- User wants emoji or unicode art (this uses standard ASCII characters) + +## Quick Start + +### Text to ASCII + +```bash +# Basic text banner +bun run scripts/render.ts --text "DUPE.COM" + +# Filled letters (inverted) +bun run scripts/render.ts --text "HELLO" --invert --cols 80 + +# Custom font and size +bun run scripts/render.ts --text "WOW" --font "Impact" --cols 60 --rows 12 +``` + +### Image to ASCII + +```bash +# Convert image +bun run scripts/render.ts photo.png --cols 80 --rows 40 + +# Demo sphere (no image needed) +bun run scripts/render.ts --demo +``` + +## Command Reference + +| Option | Short | Default | Description | +|--------|-------|---------|-------------| +| `--text` | `-t` | - | Text to render as ASCII art | +| `--cols` | `-c` | 80 | Output width in characters | +| `--rows` | `-r` | auto | Output height (auto-calculated for text) | +| `--invert` | `-i` | false | Invert colors (filled vs outlined) | +| `--font` | `-f` | Arial Black | Font for text rendering | +| `--contrast` | - | 1.5 | Contrast enhancement (higher = sharper) | +| `--demo` | `-d` | false | Render demo sphere | +| `--help` | `-h` | - | Show help | + +## Examples + +### Text Banner (Outlined) + +```bash +bun run scripts/render.ts --text "DUPE" --cols 60 +``` + +``` +@@@@@TTTTTTTT@@@@TTT@@@@TTT@@FTTTTTTT#@@FTTTTTTTT%@@@@@ +@@@@@ `#@@ #@@@ #@i ,w: %@i ,;;;;;]@@@@@ +@@@@@ ]@@i `@@ #@@@ #@i `@@i ]@i J@@@@@@@@@@@ +@@@@@ ]@@D @@ #@@@ #@i `T^ W@i @@@@@@ +@@@@@ ]@@P @@ #@@@ #@i ,cssw#@@i ,=====@@@@@@ +@@@@@ J#P' s@@. J#@t ,@@i `@@@@@@@i `@@@###@@@@@ +@@@@@ ,@@@W. ,W@@i `@@@@@@@i ]@@@@@ +@@@@@=======#@@@@@@==www=@@@@#===@@@@@@@#========#@@@@@ +``` + +### Text Banner (Filled/Inverted) + +```bash +bun run scripts/render.ts --text "DUPE" --cols 60 --invert +``` + +``` + =======w: a=== a=== a======ww ,=========: + @@@P#@@@w &@@@ J@@@ ]@@@PP#@@# `@@@@PPPPP' + @@@i `@@@i &@@D J@@@ ]@@@,,W@@@ ,@@@lwwwwc + @@@i @@@i &@@D ]@@@ ]@@@@@@@P' `@@@@PPPPP + @@@l,;w@@@' ]@@@c.,W@@@ ]@@@ ,@@@l.,.,.. + @@@@@@@@P' T@@@@@@@P' ]@@@ `@@@@@@@@@i +``` + +## How It Works: Shape Vectors + +Traditional ASCII converters map pixel brightness → single character. This creates blurry results. + +**Shape vectors** capture character *geometry* using 6 sampling regions per cell: + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ ā—ā‚€ ā—ā‚ │ ← Top samples +│ ā—ā‚‚ ā—ā‚ƒ │ ← Middle samples +│ ā—ā‚„ ā—ā‚… │ ← Bottom samples +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +Each ASCII character has a unique 6D "shape signature". The algorithm finds the best-matching character for each image cell based on geometric similarity, not just brightness. + +This produces sharp edges that follow the actual boundaries in the image. + +## Requirements + +- **Runtime:** Bun (or Node.js with modifications) +- **Dependencies:** `sharp` for image processing + +```bash +bun add sharp +``` + +## Available Fonts + +Font availability depends on your system. Common options: +- Arial Black (default, bold and clean) +- Impact (condensed, strong) +- Helvetica Bold +- Georgia +- Times New Roman +- Courier New + +## Further Reading + +- [Original blog post](https://alexharri.com/blog/ascii-rendering) - Deep dive into the algorithm +- Shape vector math and K-d tree optimization details in the source code diff --git a/skills/ascii-renderer/scripts/render.ts b/skills/ascii-renderer/scripts/render.ts new file mode 100755 index 0000000..9d0b491 --- /dev/null +++ b/skills/ascii-renderer/scripts/render.ts @@ -0,0 +1,544 @@ +#!/usr/bin/env bun + +/** + * ASCII Art Renderer using Shape Vector Algorithm + * + * Converts images or text to ASCII art with sharp edge detection. + * + * Usage: + * bun run render.ts [--cols N] [--rows N] [--contrast N] + * bun run render.ts --text "HELLO" [--font "Arial Black"] [--cols N] + * + * Examples: + * bun run render.ts photo.png + * bun run render.ts photo.jpg --cols 120 --rows 60 + * bun run render.ts --text "DUPE.COM" --cols 80 + * bun run render.ts --text "HELLO" --font "Impact" --cols 60 + */ + +import { existsSync } from 'node:fs' +import { parseArgs } from 'node:util' + +// ============================================================================ +// Character Shape Vectors +// ============================================================================ + +interface CharacterShape { + character: string + shapeVector: number[] // 6D vector [topLeft, topRight, midLeft, midRight, botLeft, botRight] +} + +// Pre-computed shape vectors for ASCII characters +// Values represent ink density in each of 6 sampling regions +const CHARACTER_SHAPES: CharacterShape[] = [ + { character: ' ', shapeVector: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0] }, + { character: '.', shapeVector: [0.0, 0.0, 0.0, 0.0, 0.2, 0.0] }, + { character: "'", shapeVector: [0.3, 0.0, 0.0, 0.0, 0.0, 0.0] }, + { character: '`', shapeVector: [0.0, 0.3, 0.0, 0.0, 0.0, 0.0] }, + { character: ',', shapeVector: [0.0, 0.0, 0.0, 0.0, 0.0, 0.3] }, + { character: ':', shapeVector: [0.0, 0.0, 0.3, 0.0, 0.3, 0.0] }, + { character: ';', shapeVector: [0.0, 0.0, 0.3, 0.0, 0.2, 0.3] }, + { character: '-', shapeVector: [0.0, 0.0, 0.8, 0.8, 0.0, 0.0] }, + { character: '=', shapeVector: [0.0, 0.0, 0.8, 0.8, 0.8, 0.8] }, + { character: '+', shapeVector: [0.0, 0.4, 0.8, 0.8, 0.0, 0.4] }, + { character: '*', shapeVector: [0.3, 0.3, 0.5, 0.5, 0.3, 0.3] }, + { character: '~', shapeVector: [0.0, 0.0, 0.6, 0.6, 0.0, 0.0] }, + { character: '^', shapeVector: [0.4, 0.4, 0.0, 0.0, 0.0, 0.0] }, + { character: '"', shapeVector: [0.4, 0.4, 0.0, 0.0, 0.0, 0.0] }, + { character: '|', shapeVector: [0.4, 0.4, 0.4, 0.4, 0.4, 0.4] }, + { character: '/', shapeVector: [0.0, 0.7, 0.4, 0.4, 0.7, 0.0] }, + { character: '\\', shapeVector: [0.7, 0.0, 0.4, 0.4, 0.0, 0.7] }, + { character: '(', shapeVector: [0.0, 0.5, 0.5, 0.0, 0.0, 0.5] }, + { character: ')', shapeVector: [0.5, 0.0, 0.0, 0.5, 0.5, 0.0] }, + { character: '[', shapeVector: [0.5, 0.5, 0.5, 0.0, 0.5, 0.5] }, + { character: ']', shapeVector: [0.5, 0.5, 0.0, 0.5, 0.5, 0.5] }, + { character: '{', shapeVector: [0.3, 0.5, 0.6, 0.0, 0.3, 0.5] }, + { character: '}', shapeVector: [0.5, 0.3, 0.0, 0.6, 0.5, 0.3] }, + { character: '<', shapeVector: [0.0, 0.5, 0.5, 0.0, 0.0, 0.5] }, + { character: '>', shapeVector: [0.5, 0.0, 0.0, 0.5, 0.5, 0.0] }, + { character: 'i', shapeVector: [0.3, 0.0, 0.5, 0.0, 0.5, 0.0] }, + { character: 'l', shapeVector: [0.5, 0.0, 0.5, 0.0, 0.5, 0.5] }, + { character: 'I', shapeVector: [0.5, 0.5, 0.4, 0.4, 0.5, 0.5] }, + { character: '!', shapeVector: [0.4, 0.0, 0.4, 0.0, 0.3, 0.0] }, + { character: 't', shapeVector: [0.5, 0.5, 0.5, 0.0, 0.3, 0.4] }, + { character: 'f', shapeVector: [0.3, 0.5, 0.5, 0.3, 0.5, 0.0] }, + { character: 'r', shapeVector: [0.0, 0.0, 0.5, 0.5, 0.5, 0.0] }, + { character: 'n', shapeVector: [0.0, 0.0, 0.6, 0.6, 0.5, 0.5] }, + { character: 'u', shapeVector: [0.0, 0.0, 0.5, 0.5, 0.5, 0.5] }, + { character: 'v', shapeVector: [0.0, 0.0, 0.5, 0.5, 0.3, 0.3] }, + { character: 'x', shapeVector: [0.0, 0.0, 0.6, 0.6, 0.6, 0.6] }, + { character: 'z', shapeVector: [0.0, 0.0, 0.6, 0.6, 0.6, 0.6] }, + { character: 'c', shapeVector: [0.0, 0.0, 0.5, 0.4, 0.5, 0.4] }, + { character: 'o', shapeVector: [0.0, 0.0, 0.5, 0.5, 0.5, 0.5] }, + { character: 'a', shapeVector: [0.0, 0.0, 0.5, 0.6, 0.5, 0.6] }, + { character: 'e', shapeVector: [0.0, 0.0, 0.6, 0.5, 0.5, 0.4] }, + { character: 's', shapeVector: [0.0, 0.0, 0.5, 0.4, 0.4, 0.5] }, + { character: 'J', shapeVector: [0.4, 0.5, 0.0, 0.5, 0.5, 0.4] }, + { character: 'L', shapeVector: [0.5, 0.0, 0.5, 0.0, 0.5, 0.5] }, + { character: 'C', shapeVector: [0.4, 0.5, 0.5, 0.0, 0.4, 0.5] }, + { character: 'U', shapeVector: [0.5, 0.5, 0.5, 0.5, 0.4, 0.4] }, + { character: 'O', shapeVector: [0.4, 0.4, 0.5, 0.5, 0.4, 0.4] }, + { character: '0', shapeVector: [0.5, 0.5, 0.5, 0.5, 0.5, 0.5] }, + { character: 'Q', shapeVector: [0.4, 0.4, 0.5, 0.5, 0.5, 0.6] }, + { character: 'Y', shapeVector: [0.5, 0.5, 0.3, 0.3, 0.3, 0.0] }, + { character: 'X', shapeVector: [0.6, 0.6, 0.4, 0.4, 0.6, 0.6] }, + { character: 'Z', shapeVector: [0.6, 0.6, 0.4, 0.4, 0.6, 0.6] }, + { character: 'm', shapeVector: [0.0, 0.0, 0.7, 0.7, 0.6, 0.6] }, + { character: 'w', shapeVector: [0.0, 0.0, 0.6, 0.6, 0.7, 0.7] }, + { character: 'q', shapeVector: [0.0, 0.0, 0.6, 0.6, 0.5, 0.6] }, + { character: 'p', shapeVector: [0.0, 0.0, 0.6, 0.6, 0.6, 0.5] }, + { character: 'd', shapeVector: [0.0, 0.5, 0.5, 0.5, 0.5, 0.5] }, + { character: 'b', shapeVector: [0.5, 0.0, 0.5, 0.5, 0.5, 0.5] }, + { character: 'k', shapeVector: [0.5, 0.0, 0.5, 0.5, 0.5, 0.5] }, + { character: 'h', shapeVector: [0.5, 0.0, 0.5, 0.5, 0.5, 0.5] }, + { character: 'A', shapeVector: [0.4, 0.4, 0.6, 0.6, 0.5, 0.5] }, + { character: 'V', shapeVector: [0.5, 0.5, 0.5, 0.5, 0.3, 0.3] }, + { character: 'T', shapeVector: [0.6, 0.6, 0.3, 0.3, 0.3, 0.0] }, + { character: 'N', shapeVector: [0.6, 0.6, 0.6, 0.6, 0.6, 0.6] }, + { character: 'H', shapeVector: [0.5, 0.5, 0.6, 0.6, 0.5, 0.5] }, + { character: 'K', shapeVector: [0.5, 0.5, 0.6, 0.5, 0.5, 0.5] }, + { character: 'D', shapeVector: [0.6, 0.5, 0.6, 0.5, 0.6, 0.5] }, + { character: 'P', shapeVector: [0.6, 0.5, 0.6, 0.5, 0.5, 0.0] }, + { character: 'R', shapeVector: [0.6, 0.5, 0.6, 0.5, 0.5, 0.5] }, + { character: 'B', shapeVector: [0.6, 0.5, 0.6, 0.5, 0.6, 0.5] }, + { character: 'E', shapeVector: [0.6, 0.6, 0.6, 0.4, 0.6, 0.6] }, + { character: 'F', shapeVector: [0.6, 0.6, 0.6, 0.4, 0.5, 0.0] }, + { character: 'G', shapeVector: [0.5, 0.6, 0.5, 0.4, 0.5, 0.6] }, + { character: 'S', shapeVector: [0.5, 0.6, 0.5, 0.5, 0.6, 0.5] }, + { character: '#', shapeVector: [0.7, 0.7, 0.8, 0.8, 0.7, 0.7] }, + { character: '%', shapeVector: [0.7, 0.5, 0.5, 0.5, 0.5, 0.7] }, + { character: '&', shapeVector: [0.5, 0.5, 0.6, 0.6, 0.6, 0.7] }, + { character: 'M', shapeVector: [0.7, 0.7, 0.6, 0.6, 0.6, 0.6] }, + { character: 'W', shapeVector: [0.6, 0.6, 0.6, 0.6, 0.7, 0.7] }, + { character: '8', shapeVector: [0.6, 0.6, 0.6, 0.6, 0.6, 0.6] }, + { character: '$', shapeVector: [0.6, 0.6, 0.6, 0.6, 0.6, 0.6] }, + { character: '@', shapeVector: [0.8, 0.8, 0.8, 0.8, 0.8, 0.7] }, +] + +// ============================================================================ +// Vector Math +// ============================================================================ + +function euclideanDistance(a: number[], b: number[]): number { + let sum = 0 + for (let i = 0; i < a.length; i++) { + sum += (a[i] - b[i]) ** 2 + } + return Math.sqrt(sum) +} + +// ============================================================================ +// Cached Character Lookup +// ============================================================================ + +const BITS = 5 +const RANGE = 2 ** BITS +const cache = new Map() + +function generateCacheKey(vector: number[]): number { + let key = 0 + for (const value of vector) { + const quantized = Math.min(RANGE - 1, Math.floor(value * RANGE)) + key = (key << BITS) | quantized + } + return key +} + +function findBestCharacter(samplingVector: number[]): string { + const key = generateCacheKey(samplingVector) + + if (cache.has(key)) { + return cache.get(key)! + } + + let bestChar = ' ' + let bestDistance = Infinity + + for (const { character, shapeVector } of CHARACTER_SHAPES) { + const dist = euclideanDistance(samplingVector, shapeVector) + if (dist < bestDistance) { + bestDistance = dist + bestChar = character + } + } + + cache.set(key, bestChar) + return bestChar +} + +// ============================================================================ +// Image Sampling +// ============================================================================ + +interface ImageData { + width: number + height: number + data: Uint8Array | number[] +} + +function sampleCircle( + imageData: ImageData, + cx: number, + cy: number, + radius: number, +): number { + let sum = 0 + let count = 0 + + const minY = Math.max(0, Math.floor(cy - radius)) + const maxY = Math.min(imageData.height - 1, Math.ceil(cy + radius)) + const minX = Math.max(0, Math.floor(cx - radius)) + const maxX = Math.min(imageData.width - 1, Math.ceil(cx + radius)) + + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + if ((x - cx) ** 2 + (y - cy) ** 2 <= radius ** 2) { + const idx = (y * imageData.width + x) * 4 + const r = imageData.data[idx] ?? 0 + const g = imageData.data[idx + 1] ?? 0 + const b = imageData.data[idx + 2] ?? 0 + // Relative luminance + sum += 0.299 * r + 0.587 * g + 0.114 * b + count++ + } + } + } + + return count > 0 ? sum / count / 255 : 0 +} + +function sampleCell( + imageData: ImageData, + cellX: number, + cellY: number, + cellWidth: number, + cellHeight: number, +): number[] { + const samplingVector: number[] = [] + + // 6 sampling positions (2x3 grid within cell) + const positions = [ + [0.25, 0.17], + [0.75, 0.17], // Top + [0.25, 0.5], + [0.75, 0.5], // Middle + [0.25, 0.83], + [0.75, 0.83], // Bottom + ] + + const radius = Math.min(cellWidth, cellHeight) * 0.15 + + for (const [px, py] of positions) { + const centerX = cellX + px * cellWidth + const centerY = cellY + py * cellHeight + samplingVector.push(sampleCircle(imageData, centerX, centerY, radius)) + } + + return samplingVector +} + +// ============================================================================ +// Contrast Enhancement +// ============================================================================ + +function enhanceContrast(samplingVector: number[], exponent: number): number[] { + const maxVal = Math.max(...samplingVector) + if (maxVal === 0) return samplingVector + + return samplingVector.map((v) => { + const norm = v / maxVal + return norm ** exponent * maxVal + }) +} + +// ============================================================================ +// Renderer +// ============================================================================ + +interface RenderOptions { + cols: number + rows: number + contrastExponent?: number + invert?: boolean +} + +function renderToAscii(imageData: ImageData, options: RenderOptions): string { + const { cols, rows, contrastExponent = 2, invert = false } = options + const cellWidth = imageData.width / cols + const cellHeight = imageData.height / rows + + const lines: string[] = [] + + for (let row = 0; row < rows; row++) { + let line = '' + for (let col = 0; col < cols; col++) { + const x = col * cellWidth + const y = row * cellHeight + + let samplingVector = sampleCell(imageData, x, y, cellWidth, cellHeight) + + // Invert if needed (for light backgrounds) + if (invert) { + samplingVector = samplingVector.map((v) => 1 - v) + } + + // Apply contrast enhancement + samplingVector = enhanceContrast(samplingVector, contrastExponent) + + line += findBestCharacter(samplingVector) + } + lines.push(line) + } + + return lines.join('\n') +} + +// ============================================================================ +// PNG Decoding (using sharp if available, fallback to raw decode) +// ============================================================================ + +async function loadImage(filePath: string): Promise { + try { + // Try using sharp for comprehensive format support + const sharp = await import('sharp') + const image = sharp.default(filePath) + const _metadata = await image.metadata() + const { data, info } = await image + .raw() + .ensureAlpha() + .toBuffer({ resolveWithObject: true }) + + return { + width: info.width, + height: info.height, + data: new Uint8Array(data), + } + } catch { + // Fallback: basic PNG decoder + console.error( + "Note: Install 'sharp' for better image format support (bun add sharp)", + ) + throw new Error('Could not load image. Please install sharp: bun add sharp') + } +} + +// ============================================================================ +// Text to Image Rendering +// ============================================================================ + +async function renderTextToImage( + text: string, + options: { + font?: string + fontSize?: number + bold?: boolean + } = {}, +): Promise { + const { font = 'Arial Black', fontSize = 120, bold = true } = options + + // Calculate dimensions based on text length + // Approximate: each character is roughly 0.6x the font size in width + const estimatedWidth = Math.ceil(text.length * fontSize * 0.7) + fontSize + const estimatedHeight = Math.ceil(fontSize * 1.5) + + // Create SVG with the text + const fontWeight = bold ? 'bold' : 'normal' + const svg = ` + + + ${escapeXml(text)} + + ` + + try { + const sharp = await import('sharp') + + // Convert SVG to PNG buffer, then to raw pixels + const { data, info } = await sharp + .default(Buffer.from(svg)) + .png() + .raw() + .ensureAlpha() + .toBuffer({ resolveWithObject: true }) + + return { + width: info.width, + height: info.height, + data: new Uint8Array(data), + } + } catch (_err) { + throw new Error( + 'Failed to render text. Make sure sharp is installed: bun add sharp', + ) + } +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +// ============================================================================ +// Demo: Generate Test Pattern +// ============================================================================ + +function generateTestPattern(width: number, height: number): ImageData { + const data = new Uint8Array(width * height * 4) + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4 + + // Create a gradient sphere + const cx = width / 2 + const cy = height / 2 + const radius = Math.min(width, height) * 0.4 + + const dx = x - cx + const dy = y - cy + const dist = Math.sqrt(dx * dx + dy * dy) + + if (dist < radius) { + // Inside sphere - gradient based on distance and angle for 3D effect + const normalZ = Math.sqrt(1 - (dist / radius) ** 2) + const lightDir = { x: -0.5, y: -0.5, z: 0.7 } + const normalX = dx / radius + const normalY = dy / radius + + const dot = + normalX * lightDir.x + normalY * lightDir.y + normalZ * lightDir.z + const brightness = Math.max(0, Math.min(255, dot * 255)) + + data[idx] = brightness + data[idx + 1] = brightness + data[idx + 2] = brightness + data[idx + 3] = 255 + } else { + // Outside sphere - white background + data[idx] = 255 + data[idx + 1] = 255 + data[idx + 2] = 255 + data[idx + 3] = 255 + } + } + } + + return { width, height, data } +} + +// ============================================================================ +// Main +// ============================================================================ + +async function main() { + const { values, positionals } = parseArgs({ + args: Bun.argv.slice(2), + options: { + cols: { type: 'string', short: 'c', default: '80' }, + rows: { type: 'string', short: 'r' }, + contrast: { type: 'string', default: '1.5' }, + invert: { type: 'boolean', short: 'i', default: false }, + demo: { type: 'boolean', short: 'd', default: false }, + text: { type: 'string', short: 't' }, + font: { type: 'string', short: 'f', default: 'Arial Black' }, + help: { type: 'boolean', short: 'h', default: false }, + }, + allowPositionals: true, + }) + + if (values.help) { + console.log(` +ASCII Art Renderer - Shape Vector Algorithm + +Usage: + bun run render.ts [options] + bun run render.ts --text "YOUR TEXT" [options] + bun run render.ts --demo [options] + +Options: + -c, --cols Output columns (default: 80) + -r, --rows Output rows (auto-calculated for text, default 40 for images) + --contrast Contrast exponent (default: 1.5, higher = more contrast) + -i, --invert Invert colors (for light images on dark terminal) + -t, --text Render text instead of an image + -f, --font Font for text rendering (default: "Arial Black") + -d, --demo Render a demo sphere + -h, --help Show this help + +Text Examples: + bun run render.ts --text "DUPE.COM" + bun run render.ts --text "HELLO" --cols 60 + bun run render.ts --text "WOW" --font "Impact" --cols 40 + +Image Examples: + bun run render.ts photo.png + bun run render.ts photo.jpg --cols 120 --rows 60 + bun run render.ts --demo --cols 60 --rows 30 + +Available Fonts (system-dependent): + Arial Black, Impact, Helvetica Bold, Georgia, Times New Roman, + Courier New, Verdana, Comic Sans MS +`) + return + } + + const cols = parseInt(values.cols!, 10) + const contrastExponent = parseFloat(values.contrast!) + + let imageData: ImageData + let rows: number + + if (values.text) { + // Render text to image + const text = values.text + imageData = await renderTextToImage(text, { + font: values.font, + fontSize: 150, + bold: true, + }) + + // Auto-calculate rows to maintain aspect ratio for text + // Terminal characters are roughly 2:1 height:width ratio + const aspectRatio = imageData.height / imageData.width + rows = values.rows + ? parseInt(values.rows, 10) + : Math.max(5, Math.ceil(cols * aspectRatio * 0.5)) + } else if (values.demo || positionals.length === 0) { + // Generate demo sphere + console.log('Rendering demo sphere...\n') + imageData = generateTestPattern(400, 400) + rows = values.rows ? parseInt(values.rows, 10) : 40 + } else { + const imagePath = positionals[0] + if (!existsSync(imagePath)) { + console.error(`Error: File not found: ${imagePath}`) + process.exit(1) + } + imageData = await loadImage(imagePath) + rows = values.rows ? parseInt(values.rows, 10) : 40 + } + + const ascii = renderToAscii(imageData, { + cols, + rows, + contrastExponent, + invert: values.invert, + }) + + console.log(ascii) +} + +main().catch(console.error) diff --git a/skills/nomistakes/README.md b/skills/nomistakes/README.md index 08ca10f..ab330ef 100644 --- a/skills/nomistakes/README.md +++ b/skills/nomistakes/README.md @@ -92,8 +92,8 @@ async function createUser( | Script | Description | |--------|-------------| -| `scripts/validate-skill.js` | Validate SKILL.md against agentskills.io spec | -| `scripts/migrate-to-biome.js` | Automated ESLint/Prettier → Biome migration | +| `scripts/validate-skill.cjs` | Validate SKILL.md against agentskills.io spec | +| `scripts/migrate-to-biome.cjs` | Automated ESLint/Prettier → Biome migration | ## License diff --git a/skills/nomistakes/metadata.json b/skills/nomistakes/metadata.json index 92275bb..9510d0b 100644 --- a/skills/nomistakes/metadata.json +++ b/skills/nomistakes/metadata.json @@ -10,13 +10,7 @@ "https://www.typescriptlang.org/docs/", "https://effect.website" ], - "compatibility": [ - "claude-code", - "opencode", - "cursor", - "goose", - "amp" - ], + "compatibility": ["claude-code", "opencode", "cursor", "goose", "amp"], "tags": [ "error-prevention", "best-practices", diff --git a/skills/nomistakes/references/BIOME_MIGRATION.md b/skills/nomistakes/references/BIOME_MIGRATION.md index 4da4a78..7a5947b 100644 --- a/skills/nomistakes/references/BIOME_MIGRATION.md +++ b/skills/nomistakes/references/BIOME_MIGRATION.md @@ -82,7 +82,7 @@ The easiest way to migrate is using the automated script: ```bash # Run migration script -node scripts/migrate-to-biome.js +node scripts/migrate-to-biome.cjs ``` **What the script does:** @@ -581,7 +581,7 @@ Use this checklist to ensure a smooth migration: - [ ] Inform team members about the migration - [ ] **Migration** - - [ ] Run `node scripts/migrate-to-biome.js` + - [ ] Run `node scripts/migrate-to-biome.cjs` - [ ] Review generated `biome.json` - [ ] Test: `npm run check` - [ ] Fix auto-fixable issues: `npm run check:fix` diff --git a/skills/nomistakes/scripts/migrate-to-biome.cjs b/skills/nomistakes/scripts/migrate-to-biome.cjs new file mode 100755 index 0000000..43156c5 --- /dev/null +++ b/skills/nomistakes/scripts/migrate-to-biome.cjs @@ -0,0 +1,546 @@ +#!/usr/bin/env node + +/** + * Migrate from ESLint/Prettier to Biome + * + * This script automates the migration to Biome (biomejs.dev), a fast Rust-based + * linter and formatter that replaces ESLint + Prettier with a single tool. + * + * Benefits: + * - 50-100x faster than ESLint + * - Zero config TypeScript support + * - Single tool for linting + formatting + * - Compatible with most ESLint rules + * + * Usage: node scripts/migrate-to-biome.cjs + * + * What it does: + * 1. Checks for existing ESLint/Prettier config + * 2. Installs Biome as dev dependency + * 3. Generates biome.json config + * 4. Removes ESLint/Prettier dependencies + * 5. Updates package.json scripts + * 6. Creates backup of removed configs + */ + +const fs = require('node:fs') +const path = require('node:path') +const { execSync } = require('node:child_process') + +// ANSI color codes +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + gray: '\x1b[90m', +} + +function log(message, color = 'reset') { + const colorCode = colors[color] ?? colors.reset + console.log(`${colorCode}${message}${colors.reset}`) +} + +function step(message) { + log(`\n→ ${message}`, 'cyan') +} + +function success(message) { + log(` āœ“ ${message}`, 'green') +} + +function warning(message) { + log(` ⚠ ${message}`, 'yellow') +} + +function error(message) { + log(` āœ— ${message}`, 'red') +} + +function info(message) { + log(` ${message}`, 'gray') +} + +// Files to check for existing linter/formatter config +const CONFIG_FILES = { + eslint: [ + '.eslintrc', + '.eslintrc.js', + '.eslintrc.cjs', + '.eslintrc.json', + '.eslintrc.yml', + '.eslintrc.yaml', + ], + prettier: [ + '.prettierrc', + '.prettierrc.js', + '.prettierrc.cjs', + '.prettierrc.json', + '.prettierrc.yml', + '.prettierrc.yaml', + 'prettier.config.js', + 'prettier.config.cjs', + ], +} + +// Packages to remove +const PACKAGES_TO_REMOVE = [ + 'eslint', + '@typescript-eslint/parser', + '@typescript-eslint/eslint-plugin', + 'eslint-config-prettier', + 'eslint-plugin-prettier', + 'prettier', +] + +function findRootDir() { + let dir = process.cwd() + let iterations = 0 + const MAX_ITERATIONS = 100 + + // Look for package.json with iteration limit to prevent infinite loops + while (dir !== '/' && iterations < MAX_ITERATIONS) { + if (fs.existsSync(path.join(dir, 'package.json'))) { + return dir + } + dir = path.dirname(dir) + iterations++ + } + + return process.cwd() +} + +function findExistingConfigs(rootDir) { + const found = { + eslint: [], + prettier: [], + } + + // Check for ESLint configs + CONFIG_FILES.eslint.forEach((file) => { + const filePath = path.join(rootDir, file) + if (fs.existsSync(filePath)) { + found.eslint.push(file) + } + }) + + // Check for Prettier configs + CONFIG_FILES.prettier.forEach((file) => { + const filePath = path.join(rootDir, file) + if (fs.existsSync(filePath)) { + found.prettier.push(file) + } + }) + + // Check package.json for embedded configs + const pkgPath = path.join(rootDir, 'package.json') + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) + if (pkg && typeof pkg === 'object' && !Array.isArray(pkg)) { + if (pkg.eslintConfig) { + found.eslint.push('package.json (eslintConfig field)') + } + if (pkg.prettier) { + found.prettier.push('package.json (prettier field)') + } + } + } catch (e) { + warning(`Could not parse package.json: ${e.message}`) + } + } + + return found +} + +function backupConfigs(rootDir, configs) { + const backupDir = path.join(rootDir, '.biome-migration-backup') + + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }) + } + + const backedUp = [] + + ;[...configs.eslint, ...configs.prettier].forEach((file) => { + if (file.includes('package.json')) return // Handle separately + + const srcPath = path.join(rootDir, file) + const destPath = path.join(backupDir, file) + + if (fs.existsSync(srcPath)) { + fs.copyFileSync(srcPath, destPath) + backedUp.push(file) + } + }) + + return { backupDir, backedUp } +} + +function generateBiomeConfig() { + return { + $schema: 'https://biomejs.dev/schemas/1.9.4/schema.json', + vcs: { + enabled: true, + clientKind: 'git', + useIgnoreFile: true, + }, + files: { + ignoreUnknown: false, + ignore: [ + 'node_modules', + 'dist', + 'build', + '.next', + '.nuxt', + 'coverage', + '*.min.js', + ], + }, + formatter: { + enabled: true, + formatWithErrors: false, + indentStyle: 'space', + indentWidth: 2, + lineEnding: 'lf', + lineWidth: 100, + attributePosition: 'auto', + }, + organizeImports: { + enabled: true, + }, + linter: { + enabled: true, + rules: { + recommended: true, + correctness: { + noUnusedVariables: 'error', + noUnusedImports: 'error', + }, + style: { + useConst: 'error', + useTemplate: 'error', + }, + suspicious: { + noExplicitAny: 'warn', + noConsoleLog: 'warn', + }, + }, + }, + javascript: { + formatter: { + quoteStyle: 'single', + jsxQuoteStyle: 'double', + quoteProperties: 'asNeeded', + trailingCommas: 'all', + semicolons: 'always', + arrowParentheses: 'always', + bracketSpacing: true, + bracketSameLine: false, + }, + }, + json: { + formatter: { + enabled: true, + indentStyle: 'space', + indentWidth: 2, + lineWidth: 100, + }, + linter: { + enabled: true, + }, + }, + } +} + +function updatePackageJson(rootDir) { + const pkgPath = path.join(rootDir, 'package.json') + + let pkg + try { + pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) + } catch (e) { + throw new Error(`Failed to parse package.json: ${e.message}`) + } + + // Validate pkg structure + if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) { + throw new Error('Invalid package.json structure') + } + + // Remove old config fields + const removedFields = [] + if (pkg.eslintConfig) { + delete pkg.eslintConfig + removedFields.push('eslintConfig') + } + if (pkg.prettier) { + delete pkg.prettier + removedFields.push('prettier') + } + + // Update scripts + const oldScripts = { ...(pkg.scripts || {}) } + pkg.scripts = pkg.scripts || {} + + // Replace lint/format scripts + const scriptUpdates = { + lint: 'biome lint .', + format: 'biome format --write .', + 'format:check': 'biome format .', + check: 'biome check .', + 'check:fix': 'biome check --write .', + } + + Object.entries(scriptUpdates).forEach(([key, value]) => { + pkg.scripts[key] = value + }) + + try { + fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`) + } catch (e) { + throw new Error(`Failed to write package.json: ${e.message}`) + } + + return { removedFields, oldScripts } +} + +function detectPackageManager(rootDir) { + if (fs.existsSync(path.join(rootDir, 'pnpm-lock.yaml'))) { + return 'pnpm' + } + if (fs.existsSync(path.join(rootDir, 'yarn.lock'))) { + return 'yarn' + } + if (fs.existsSync(path.join(rootDir, 'bun.lockb'))) { + return 'bun' + } + return 'npm' +} + +function installBiome(rootDir, packageManager) { + const commands = { + npm: 'npm install --save-dev --save-exact @biomejs/biome', + yarn: 'yarn add --dev --exact @biomejs/biome', + pnpm: 'pnpm add --save-dev --save-exact @biomejs/biome', + bun: 'bun add --dev --exact @biomejs/biome', + } + + const command = commands[packageManager] + if (!command) { + throw new Error(`Unknown package manager: ${packageManager}`) + } + + try { + execSync(command, { cwd: rootDir, stdio: 'inherit' }) + } catch (e) { + throw new Error(`Failed to install Biome: ${e.message}`) + } +} + +function removeOldPackages(rootDir, packageManager) { + const pkgPath = path.join(rootDir, 'package.json') + + let pkg + try { + pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) + } catch (e) { + throw new Error(`Failed to parse package.json: ${e.message}`) + } + + // Validate pkg structure + if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) { + throw new Error('Invalid package.json structure') + } + + const toRemove = PACKAGES_TO_REMOVE.filter((name) => { + return pkg.devDependencies?.[name] || pkg.dependencies?.[name] + }) + + if (toRemove.length === 0) { + return [] + } + + const commands = { + npm: `npm uninstall ${toRemove.join(' ')}`, + yarn: `yarn remove ${toRemove.join(' ')}`, + pnpm: `pnpm remove ${toRemove.join(' ')}`, + bun: `bun remove ${toRemove.join(' ')}`, + } + + const command = commands[packageManager] + if (!command) { + throw new Error(`Unknown package manager: ${packageManager}`) + } + + try { + execSync(command, { cwd: rootDir, stdio: 'inherit' }) + } catch (e) { + throw new Error(`Failed to remove packages: ${e.message}`) + } + + return toRemove +} + +function removeConfigFiles(rootDir, configs) { + const removed = [] + + ;[...configs.eslint, ...configs.prettier].forEach((file) => { + if (file.includes('package.json')) return // Already handled + + const filePath = path.join(rootDir, file) + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + removed.push(file) + } + }) + + return removed +} + +async function main() { + log('\n╔═══════════════════════════════════════╗', 'cyan') + log('ā•‘ Biome Migration Script ā•‘', 'cyan') + log('ā•‘ ESLint + Prettier → Biome ā•‘', 'cyan') + log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•\n', 'cyan') + + const rootDir = findRootDir() + log(`Working directory: ${rootDir}\n`, 'gray') + + // Step 1: Check existing configs + step('Checking for existing ESLint/Prettier configuration...') + const configs = findExistingConfigs(rootDir) + + if (configs.eslint.length === 0 && configs.prettier.length === 0) { + warning('No ESLint or Prettier configuration found.') + warning('Biome will be installed with default configuration.') + } else { + if (configs.eslint.length > 0) { + success(`Found ESLint config: ${configs.eslint.join(', ')}`) + } + if (configs.prettier.length > 0) { + success(`Found Prettier config: ${configs.prettier.join(', ')}`) + } + } + + // Step 2: Backup existing configs + if (configs.eslint.length > 0 || configs.prettier.length > 0) { + step('Creating backup of existing configurations...') + const { backupDir, backedUp } = backupConfigs(rootDir, configs) + success( + `Backed up ${backedUp.length} file(s) to ${path.relative(rootDir, backupDir)}/`, + ) + backedUp.forEach((file) => info(` - ${file}`)) + } + + // Step 3: Detect package manager + step('Detecting package manager...') + const packageManager = detectPackageManager(rootDir) + success(`Using ${packageManager}`) + + // Step 4: Install Biome + step('Installing @biomejs/biome...') + try { + installBiome(rootDir, packageManager) + success('Biome installed successfully') + } catch (err) { + error('Failed to install Biome') + error(err.message) + process.exit(1) + } + + // Step 5: Generate biome.json + step('Generating biome.json configuration...') + const biomeConfig = generateBiomeConfig() + const biomeConfigPath = path.join(rootDir, 'biome.json') + try { + fs.writeFileSync( + biomeConfigPath, + `${JSON.stringify(biomeConfig, null, 2)}\n`, + ) + success('Created biome.json') + } catch (e) { + error(`Failed to write biome.json: ${e.message}`) + process.exit(1) + } + + // Step 6: Update package.json + step('Updating package.json scripts...') + const { removedFields } = updatePackageJson(rootDir) + success('Updated package.json scripts') + if (removedFields.length > 0) { + info(`Removed fields: ${removedFields.join(', ')}`) + } + + // Step 7: Remove old packages + step('Removing ESLint/Prettier packages...') + try { + const removed = removeOldPackages(rootDir, packageManager) + if (removed.length > 0) { + success(`Removed ${removed.length} package(s):`) + removed.forEach((pkg) => info(` - ${pkg}`)) + } else { + info('No old packages to remove') + } + } catch (err) { + warning('Failed to remove old packages') + warning('You can manually remove them later') + info(err.message) + } + + // Step 8: Remove old config files + if (configs.eslint.length > 0 || configs.prettier.length > 0) { + step('Removing old configuration files...') + const removed = removeConfigFiles(rootDir, configs) + if (removed.length > 0) { + success(`Removed ${removed.length} config file(s):`) + removed.forEach((file) => info(` - ${file}`)) + } + } + + // Summary + log('\n╔═══════════════════════════════════════╗', 'green') + log('ā•‘ Migration Complete! šŸŽ‰ ā•‘', 'green') + log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•\n', 'green') + + log('Next steps:', 'cyan') + log(' 1. Review biome.json and adjust rules as needed', 'gray') + log(' 2. Run: npm run check (or yarn/pnpm/bun check)', 'gray') + log(' 3. Run: npm run format (or yarn/pnpm/bun format)', 'gray') + log(' 4. Update your CI/CD scripts to use Biome', 'gray') + log(' 5. Update editor integrations (see docs/BIOME_MIGRATION.md)', 'gray') + + log('\nAvailable scripts:', 'cyan') + log(' npm run lint - Run linter', 'gray') + log(' npm run format - Format code', 'gray') + log(' npm run check - Lint + format check', 'gray') + log(' npm run check:fix - Lint + format with auto-fix', 'gray') + + if (configs.eslint.length > 0 || configs.prettier.length > 0) { + log('\nBackup location:', 'yellow') + log( + ' .biome-migration-backup/ (safe to delete after verification)', + 'gray', + ) + } + + log('\nDocumentation:', 'cyan') + log(' • Migration guide: docs/BIOME_MIGRATION.md', 'gray') + log(' • Biome docs: https://biomejs.dev', 'gray') + log('') +} + +// Run migration +main().catch((err) => { + error('\nMigration failed:') + error(err.message) + // Only show full stack trace in debug mode to avoid overwhelming users + if (process.env.DEBUG) { + console.error(err) + } else { + info('Set DEBUG=1 for full stack trace') + } + process.exit(1) +}) diff --git a/skills/nomistakes/scripts/migrate-to-biome.js b/skills/nomistakes/scripts/migrate-to-biome.js deleted file mode 100755 index b150f58..0000000 --- a/skills/nomistakes/scripts/migrate-to-biome.js +++ /dev/null @@ -1,539 +0,0 @@ -#!/usr/bin/env node - -/** - * Migrate from ESLint/Prettier to Biome - * - * This script automates the migration to Biome (biomejs.dev), a fast Rust-based - * linter and formatter that replaces ESLint + Prettier with a single tool. - * - * Benefits: - * - 50-100x faster than ESLint - * - Zero config TypeScript support - * - Single tool for linting + formatting - * - Compatible with most ESLint rules - * - * Usage: node scripts/migrate-to-biome.js - * - * What it does: - * 1. Checks for existing ESLint/Prettier config - * 2. Installs Biome as dev dependency - * 3. Generates biome.json config - * 4. Removes ESLint/Prettier dependencies - * 5. Updates package.json scripts - * 6. Creates backup of removed configs - */ - -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); - -// ANSI color codes -const colors = { - reset: '\x1b[0m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - cyan: '\x1b[36m', - gray: '\x1b[90m', -}; - -function log(message, color = 'reset') { - const colorCode = colors[color] ?? colors.reset; - console.log(`${colorCode}${message}${colors.reset}`); -} - -function step(message) { - log(`\n→ ${message}`, 'cyan'); -} - -function success(message) { - log(` āœ“ ${message}`, 'green'); -} - -function warning(message) { - log(` ⚠ ${message}`, 'yellow'); -} - -function error(message) { - log(` āœ— ${message}`, 'red'); -} - -function info(message) { - log(` ${message}`, 'gray'); -} - -// Files to check for existing linter/formatter config -const CONFIG_FILES = { - eslint: [ - '.eslintrc', - '.eslintrc.js', - '.eslintrc.cjs', - '.eslintrc.json', - '.eslintrc.yml', - '.eslintrc.yaml', - ], - prettier: [ - '.prettierrc', - '.prettierrc.js', - '.prettierrc.cjs', - '.prettierrc.json', - '.prettierrc.yml', - '.prettierrc.yaml', - 'prettier.config.js', - 'prettier.config.cjs', - ], -}; - -// Packages to remove -const PACKAGES_TO_REMOVE = [ - 'eslint', - '@typescript-eslint/parser', - '@typescript-eslint/eslint-plugin', - 'eslint-config-prettier', - 'eslint-plugin-prettier', - 'prettier', -]; - -function findRootDir() { - let dir = process.cwd(); - let iterations = 0; - const MAX_ITERATIONS = 100; - - // Look for package.json with iteration limit to prevent infinite loops - while (dir !== '/' && iterations < MAX_ITERATIONS) { - if (fs.existsSync(path.join(dir, 'package.json'))) { - return dir; - } - dir = path.dirname(dir); - iterations++; - } - - return process.cwd(); -} - -function findExistingConfigs(rootDir) { - const found = { - eslint: [], - prettier: [], - }; - - // Check for ESLint configs - CONFIG_FILES.eslint.forEach(file => { - const filePath = path.join(rootDir, file); - if (fs.existsSync(filePath)) { - found.eslint.push(file); - } - }); - - // Check for Prettier configs - CONFIG_FILES.prettier.forEach(file => { - const filePath = path.join(rootDir, file); - if (fs.existsSync(filePath)) { - found.prettier.push(file); - } - }); - - // Check package.json for embedded configs - const pkgPath = path.join(rootDir, 'package.json'); - if (fs.existsSync(pkgPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - if (pkg && typeof pkg === 'object' && !Array.isArray(pkg)) { - if (pkg.eslintConfig) { - found.eslint.push('package.json (eslintConfig field)'); - } - if (pkg.prettier) { - found.prettier.push('package.json (prettier field)'); - } - } - } catch (e) { - warning(`Could not parse package.json: ${e.message}`); - } - } - - return found; -} - -function backupConfigs(rootDir, configs) { - const backupDir = path.join(rootDir, '.biome-migration-backup'); - - if (!fs.existsSync(backupDir)) { - fs.mkdirSync(backupDir, { recursive: true }); - } - - const backedUp = []; - - [...configs.eslint, ...configs.prettier].forEach(file => { - if (file.includes('package.json')) return; // Handle separately - - const srcPath = path.join(rootDir, file); - const destPath = path.join(backupDir, file); - - if (fs.existsSync(srcPath)) { - fs.copyFileSync(srcPath, destPath); - backedUp.push(file); - } - }); - - return { backupDir, backedUp }; -} - -function generateBiomeConfig() { - return { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "ignoreUnknown": false, - "ignore": [ - "node_modules", - "dist", - "build", - ".next", - ".nuxt", - "coverage", - "*.min.js" - ] - }, - "formatter": { - "enabled": true, - "formatWithErrors": false, - "indentStyle": "space", - "indentWidth": 2, - "lineEnding": "lf", - "lineWidth": 100, - "attributePosition": "auto" - }, - "organizeImports": { - "enabled": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "correctness": { - "noUnusedVariables": "error", - "noUnusedImports": "error" - }, - "style": { - "useConst": "error", - "useTemplate": "error" - }, - "suspicious": { - "noExplicitAny": "warn", - "noConsoleLog": "warn" - } - } - }, - "javascript": { - "formatter": { - "quoteStyle": "single", - "jsxQuoteStyle": "double", - "quoteProperties": "asNeeded", - "trailingCommas": "all", - "semicolons": "always", - "arrowParentheses": "always", - "bracketSpacing": true, - "bracketSameLine": false - } - }, - "json": { - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 100 - }, - "linter": { - "enabled": true - } - } - }; -} - -function updatePackageJson(rootDir) { - const pkgPath = path.join(rootDir, 'package.json'); - - let pkg; - try { - pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - } catch (e) { - throw new Error(`Failed to parse package.json: ${e.message}`); - } - - // Validate pkg structure - if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) { - throw new Error('Invalid package.json structure'); - } - - // Remove old config fields - const removedFields = []; - if (pkg.eslintConfig) { - delete pkg.eslintConfig; - removedFields.push('eslintConfig'); - } - if (pkg.prettier) { - delete pkg.prettier; - removedFields.push('prettier'); - } - - // Update scripts - const oldScripts = { ...(pkg.scripts || {}) }; - pkg.scripts = pkg.scripts || {}; - - // Replace lint/format scripts - const scriptUpdates = { - 'lint': 'biome lint .', - 'format': 'biome format --write .', - 'format:check': 'biome format .', - 'check': 'biome check .', - 'check:fix': 'biome check --write .', - }; - - Object.entries(scriptUpdates).forEach(([key, value]) => { - pkg.scripts[key] = value; - }); - - try { - fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); - } catch (e) { - throw new Error(`Failed to write package.json: ${e.message}`); - } - - return { removedFields, oldScripts }; -} - -function detectPackageManager(rootDir) { - if (fs.existsSync(path.join(rootDir, 'pnpm-lock.yaml'))) { - return 'pnpm'; - } - if (fs.existsSync(path.join(rootDir, 'yarn.lock'))) { - return 'yarn'; - } - if (fs.existsSync(path.join(rootDir, 'bun.lockb'))) { - return 'bun'; - } - return 'npm'; -} - -function installBiome(rootDir, packageManager) { - const commands = { - npm: 'npm install --save-dev --save-exact @biomejs/biome', - yarn: 'yarn add --dev --exact @biomejs/biome', - pnpm: 'pnpm add --save-dev --save-exact @biomejs/biome', - bun: 'bun add --dev --exact @biomejs/biome', - }; - - const command = commands[packageManager]; - if (!command) { - throw new Error(`Unknown package manager: ${packageManager}`); - } - - try { - execSync(command, { cwd: rootDir, stdio: 'inherit' }); - } catch (e) { - throw new Error(`Failed to install Biome: ${e.message}`); - } -} - -function removeOldPackages(rootDir, packageManager) { - const pkgPath = path.join(rootDir, 'package.json'); - - let pkg; - try { - pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - } catch (e) { - throw new Error(`Failed to parse package.json: ${e.message}`); - } - - // Validate pkg structure - if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) { - throw new Error('Invalid package.json structure'); - } - - const toRemove = PACKAGES_TO_REMOVE.filter(name => { - return (pkg.devDependencies && pkg.devDependencies[name]) || - (pkg.dependencies && pkg.dependencies[name]); - }); - - if (toRemove.length === 0) { - return []; - } - - const commands = { - npm: `npm uninstall ${toRemove.join(' ')}`, - yarn: `yarn remove ${toRemove.join(' ')}`, - pnpm: `pnpm remove ${toRemove.join(' ')}`, - bun: `bun remove ${toRemove.join(' ')}`, - }; - - const command = commands[packageManager]; - if (!command) { - throw new Error(`Unknown package manager: ${packageManager}`); - } - - try { - execSync(command, { cwd: rootDir, stdio: 'inherit' }); - } catch (e) { - throw new Error(`Failed to remove packages: ${e.message}`); - } - - return toRemove; -} - -function removeConfigFiles(rootDir, configs) { - const removed = []; - - [...configs.eslint, ...configs.prettier].forEach(file => { - if (file.includes('package.json')) return; // Already handled - - const filePath = path.join(rootDir, file); - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - removed.push(file); - } - }); - - return removed; -} - -async function main() { - log('\n╔═══════════════════════════════════════╗', 'cyan'); - log('ā•‘ Biome Migration Script ā•‘', 'cyan'); - log('ā•‘ ESLint + Prettier → Biome ā•‘', 'cyan'); - log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•\n', 'cyan'); - - const rootDir = findRootDir(); - log(`Working directory: ${rootDir}\n`, 'gray'); - - // Step 1: Check existing configs - step('Checking for existing ESLint/Prettier configuration...'); - const configs = findExistingConfigs(rootDir); - - if (configs.eslint.length === 0 && configs.prettier.length === 0) { - warning('No ESLint or Prettier configuration found.'); - warning('Biome will be installed with default configuration.'); - } else { - if (configs.eslint.length > 0) { - success(`Found ESLint config: ${configs.eslint.join(', ')}`); - } - if (configs.prettier.length > 0) { - success(`Found Prettier config: ${configs.prettier.join(', ')}`); - } - } - - // Step 2: Backup existing configs - if (configs.eslint.length > 0 || configs.prettier.length > 0) { - step('Creating backup of existing configurations...'); - const { backupDir, backedUp } = backupConfigs(rootDir, configs); - success(`Backed up ${backedUp.length} file(s) to ${path.relative(rootDir, backupDir)}/`); - backedUp.forEach(file => info(` - ${file}`)); - } - - // Step 3: Detect package manager - step('Detecting package manager...'); - const packageManager = detectPackageManager(rootDir); - success(`Using ${packageManager}`); - - // Step 4: Install Biome - step('Installing @biomejs/biome...'); - try { - installBiome(rootDir, packageManager); - success('Biome installed successfully'); - } catch (err) { - error('Failed to install Biome'); - error(err.message); - process.exit(1); - } - - // Step 5: Generate biome.json - step('Generating biome.json configuration...'); - const biomeConfig = generateBiomeConfig(); - const biomeConfigPath = path.join(rootDir, 'biome.json'); - try { - fs.writeFileSync(biomeConfigPath, JSON.stringify(biomeConfig, null, 2) + '\n'); - success('Created biome.json'); - } catch (e) { - error(`Failed to write biome.json: ${e.message}`); - process.exit(1); - } - - // Step 6: Update package.json - step('Updating package.json scripts...'); - const { removedFields, oldScripts } = updatePackageJson(rootDir); - success('Updated package.json scripts'); - if (removedFields.length > 0) { - info(`Removed fields: ${removedFields.join(', ')}`); - } - - // Step 7: Remove old packages - step('Removing ESLint/Prettier packages...'); - try { - const removed = removeOldPackages(rootDir, packageManager); - if (removed.length > 0) { - success(`Removed ${removed.length} package(s):`); - removed.forEach(pkg => info(` - ${pkg}`)); - } else { - info('No old packages to remove'); - } - } catch (err) { - warning('Failed to remove old packages'); - warning('You can manually remove them later'); - info(err.message); - } - - // Step 8: Remove old config files - if (configs.eslint.length > 0 || configs.prettier.length > 0) { - step('Removing old configuration files...'); - const removed = removeConfigFiles(rootDir, configs); - if (removed.length > 0) { - success(`Removed ${removed.length} config file(s):`); - removed.forEach(file => info(` - ${file}`)); - } - } - - // Summary - log('\n╔═══════════════════════════════════════╗', 'green'); - log('ā•‘ Migration Complete! šŸŽ‰ ā•‘', 'green'); - log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•\n', 'green'); - - log('Next steps:', 'cyan'); - log(' 1. Review biome.json and adjust rules as needed', 'gray'); - log(' 2. Run: npm run check (or yarn/pnpm/bun check)', 'gray'); - log(' 3. Run: npm run format (or yarn/pnpm/bun format)', 'gray'); - log(' 4. Update your CI/CD scripts to use Biome', 'gray'); - log(' 5. Update editor integrations (see docs/BIOME_MIGRATION.md)', 'gray'); - - log('\nAvailable scripts:', 'cyan'); - log(' npm run lint - Run linter', 'gray'); - log(' npm run format - Format code', 'gray'); - log(' npm run check - Lint + format check', 'gray'); - log(' npm run check:fix - Lint + format with auto-fix', 'gray'); - - if (configs.eslint.length > 0 || configs.prettier.length > 0) { - log('\nBackup location:', 'yellow'); - log(' .biome-migration-backup/ (safe to delete after verification)', 'gray'); - } - - log('\nDocumentation:', 'cyan'); - log(' • Migration guide: docs/BIOME_MIGRATION.md', 'gray'); - log(' • Biome docs: https://biomejs.dev', 'gray'); - log(''); -} - -// Run migration -main().catch(err => { - error('\nMigration failed:'); - error(err.message); - // Only show full stack trace in debug mode to avoid overwhelming users - if (process.env.DEBUG) { - console.error(err); - } else { - info('Set DEBUG=1 for full stack trace'); - } - process.exit(1); -}); diff --git a/skills/nomistakes/scripts/validate-skill.cjs b/skills/nomistakes/scripts/validate-skill.cjs new file mode 100755 index 0000000..5c7e270 --- /dev/null +++ b/skills/nomistakes/scripts/validate-skill.cjs @@ -0,0 +1,298 @@ +#!/usr/bin/env node + +/** + * Validate SKILL.md against agentskills.io specification + * + * Usage: node scripts/validate-skill.cjs [path-to-SKILL.md] + * + * Validates: + * - YAML frontmatter structure (starts/ends with ---) + * - Required fields: name, description + * - Field constraints (name format, description length) + * - Line count recommendation (< 500 lines) + * - Optional metadata fields format + */ + +const fs = require('node:fs') +const path = require('node:path') + +// ANSI color codes for output +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', +} + +function log(message, color = 'reset') { + const colorCode = colors[color] ?? colors.reset + console.log(`${colorCode}${message}${colors.reset}`) +} + +// Constants +const FRONTMATTER_DELIMITER = '---\n' + +function validateSkillMd(filePath) { + log('\n=== SKILL.md Validation ===\n', 'cyan') + + // Input validation + if (!filePath || typeof filePath !== 'string') { + log('āœ— Invalid file path provided', 'red') + process.exit(1) + } + + const errors = [] + const warnings = [] + const info = [] + + // Read file with error handling + if (!fs.existsSync(filePath)) { + log(`āœ— File not found: ${filePath}`, 'red') + process.exit(1) + } + + let content + try { + content = fs.readFileSync(filePath, 'utf8') + } catch (e) { + log(`āœ— Failed to read file: ${e.message}`, 'red') + process.exit(1) + } + const lines = content.split('\n') + + // Check line count + const lineCount = lines.length + info.push(`Line count: ${lineCount}`) + if (lineCount > 500) { + warnings.push( + `Line count (${lineCount}) exceeds recommended maximum of 500 lines. Consider moving detailed content to references/ directory.`, + ) + } + + // Extract frontmatter + if (!content.startsWith(FRONTMATTER_DELIMITER)) { + errors.push('SKILL.md must start with YAML frontmatter delimiter (---)') + return printResults(errors, warnings, info) + } + + const frontmatterEnd = content.indexOf( + `\n${FRONTMATTER_DELIMITER.slice(0, -1)}`, + 4, + ) + if (frontmatterEnd === -1) { + errors.push('YAML frontmatter must end with --- delimiter') + return printResults(errors, warnings, info) + } + + const frontmatter = content.substring(4, frontmatterEnd) + const frontmatterLines = frontmatter.split('\n') + + // Parse frontmatter (simple YAML parser for our needs) + const metadata = {} + let currentArray = null + + for (const line of frontmatterLines) { + if (line.trim() === '') continue + + // Array item + if (line.trim().startsWith('- ')) { + if (currentArray) { + currentArray.push(line.trim().substring(2)) + } + continue + } + + // Key-value pair + const colonIndex = line.indexOf(':') + if (colonIndex > 0) { + const key = line.substring(0, colonIndex).trim() + const value = line.substring(colonIndex + 1).trim() + + if (value === '') { + // Array start + currentArray = [] + metadata[key] = currentArray + } else { + // Simple value + metadata[key] = value + currentArray = null + } + } + } + + // Validate required fields + if (!metadata.name) { + errors.push('Required field missing: name') + } else { + // Validate name format (lowercase-hyphenated) + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(metadata.name)) { + errors.push( + `Invalid name format: "${metadata.name}". Must be lowercase-hyphenated (e.g., "my-skill-name")`, + ) + } + + // Check name matches directory + const dirName = path.basename(path.dirname(filePath)) + if (dirName !== '.' && dirName !== metadata.name) { + warnings.push( + `Skill name "${metadata.name}" does not match directory name "${dirName}"`, + ) + } + + info.push(`Name: ${metadata.name}`) + } + + if (!metadata.description) { + errors.push('Required field missing: description') + } else { + const descLength = metadata.description.length + if (descLength < 1 || descLength > 1024) { + errors.push( + `Description length (${descLength}) must be between 1 and 1024 characters`, + ) + } + info.push(`Description: ${descLength} characters`) + + // Check for discovery keywords + const keywords = ['when', 'use', 'helps', 'for'] + const hasKeywords = keywords.some((kw) => + metadata.description.toLowerCase().includes(kw), + ) + if (!hasKeywords) { + warnings.push( + 'Description should include discovery keywords (when to use this skill) to help agents find it', + ) + } + } + + // Validate optional fields + if (metadata.version) { + if (!/^\d+\.\d+\.\d+/.test(metadata.version)) { + warnings.push( + `Version "${metadata.version}" should follow semver format (e.g., 1.0.0)`, + ) + } + info.push(`Version: ${metadata.version}`) + } + + if (metadata.license) { + info.push(`License: ${metadata.license}`) + } + + if (metadata.author) { + info.push(`Author: ${metadata.author}`) + } + + if (metadata.tags) { + if (Array.isArray(metadata.tags)) { + info.push(`Tags: ${metadata.tags.length} (${metadata.tags.join(', ')})`) + } else { + warnings.push('Tags field should be an array') + } + } + + if (metadata.compatibility) { + if (Array.isArray(metadata.compatibility)) { + info.push(`Compatibility: ${metadata.compatibility.join(', ')}`) + } else { + warnings.push('Compatibility field should be an array') + } + } + + // Check for recommended sections + const contentBody = content.substring(frontmatterEnd + 5) + + const recommendedSections = [ + { name: 'When to Use This Skill', pattern: /##\s+When to Use/i }, + { name: 'When NOT to Use This Skill', pattern: /##\s+When NOT to Use/i }, + ] + + for (const section of recommendedSections) { + if (!section.pattern.test(contentBody)) { + warnings.push(`Recommended section missing: "${section.name}"`) + } + } + + // Check for references directory + const referencesDir = path.join(path.dirname(filePath), 'references') + if (fs.existsSync(referencesDir)) { + try { + const refFiles = fs.readdirSync(referencesDir) + if (refFiles.length > 0) { + info.push( + `References: ${refFiles.length} file(s) (${refFiles.join(', ')})`, + ) + } else { + warnings.push('References directory exists but is empty') + } + } catch (e) { + // Log with context for debugging, but continue validation + warnings.push(`Could not read references directory: ${e.message}`) + } + } + + // Check for scripts directory + const scriptsDir = path.join(path.dirname(filePath), 'scripts') + if (fs.existsSync(scriptsDir)) { + try { + const scriptFiles = fs.readdirSync(scriptsDir) + if (scriptFiles.length > 0) { + info.push( + `Scripts: ${scriptFiles.length} file(s) (${scriptFiles.join(', ')})`, + ) + } else { + warnings.push('Scripts directory exists but is empty') + } + } catch (e) { + // Log with context for debugging, but continue validation + warnings.push(`Could not read scripts directory: ${e.message}`) + } + } + + printResults(errors, warnings, info) +} + +function printResults(errors, warnings, info) { + // Print info + if (info.length > 0) { + log('\nℹ Info:', 'blue') + info.forEach((msg) => log(` ${msg}`, 'blue')) + } + + // Print warnings + if (warnings.length > 0) { + log('\n⚠ Warnings:', 'yellow') + warnings.forEach((msg) => log(` ${msg}`, 'yellow')) + } + + // Print errors + if (errors.length > 0) { + log('\nāœ— Errors:', 'red') + errors.forEach((msg) => log(` ${msg}`, 'red')) + log(`\nāŒ Validation FAILED (${errors.length} error(s))\n`, 'red') + process.exit(1) + } + + if (warnings.length > 0) { + log(`\nāš ļø Validation passed with ${warnings.length} warning(s)\n`, 'yellow') + } else { + log('\nāœ“ Validation PASSED\n', 'green') + } +} + +// Main +const args = process.argv.slice(2) +const skillPath = args[0] || 'SKILL.md' + +// Wrap main execution in try/catch to ensure graceful exit +try { + validateSkillMd(skillPath) +} catch (e) { + log(`\nāœ— Unexpected error during validation: ${e.message}`, 'red') + if (process.env.DEBUG) { + console.error(e) + } + process.exit(1) +} diff --git a/skills/nomistakes/scripts/validate-skill.js b/skills/nomistakes/scripts/validate-skill.js deleted file mode 100755 index 0c3e8f7..0000000 --- a/skills/nomistakes/scripts/validate-skill.js +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env node - -/** - * Validate SKILL.md against agentskills.io specification - * - * Usage: node scripts/validate-skill.js [path-to-SKILL.md] - * - * Validates: - * - YAML frontmatter structure (starts/ends with ---) - * - Required fields: name, description - * - Field constraints (name format, description length) - * - Line count recommendation (< 500 lines) - * - Optional metadata fields format - */ - -const fs = require('fs'); -const path = require('path'); - -// ANSI color codes for output -const colors = { - reset: '\x1b[0m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - cyan: '\x1b[36m', -}; - -function log(message, color = 'reset') { - const colorCode = colors[color] ?? colors.reset; - console.log(`${colorCode}${message}${colors.reset}`); -} - -// Constants -const FRONTMATTER_DELIMITER = '---\n'; - -function validateSkillMd(filePath) { - log('\n=== SKILL.md Validation ===\n', 'cyan'); - - // Input validation - if (!filePath || typeof filePath !== 'string') { - log('āœ— Invalid file path provided', 'red'); - process.exit(1); - } - - const errors = []; - const warnings = []; - const info = []; - - // Read file with error handling - if (!fs.existsSync(filePath)) { - log(`āœ— File not found: ${filePath}`, 'red'); - process.exit(1); - } - - let content; - try { - content = fs.readFileSync(filePath, 'utf8'); - } catch (e) { - log(`āœ— Failed to read file: ${e.message}`, 'red'); - process.exit(1); - } - const lines = content.split('\n'); - - // Check line count - const lineCount = lines.length; - info.push(`Line count: ${lineCount}`); - if (lineCount > 500) { - warnings.push(`Line count (${lineCount}) exceeds recommended maximum of 500 lines. Consider moving detailed content to references/ directory.`); - } - - // Extract frontmatter - if (!content.startsWith(FRONTMATTER_DELIMITER)) { - errors.push('SKILL.md must start with YAML frontmatter delimiter (---)'); - return printResults(errors, warnings, info); - } - - const frontmatterEnd = content.indexOf('\n' + FRONTMATTER_DELIMITER.slice(0, -1), 4); - if (frontmatterEnd === -1) { - errors.push('YAML frontmatter must end with --- delimiter'); - return printResults(errors, warnings, info); - } - - const frontmatter = content.substring(4, frontmatterEnd); - const frontmatterLines = frontmatter.split('\n'); - - // Parse frontmatter (simple YAML parser for our needs) - const metadata = {}; - let currentArray = null; - - for (const line of frontmatterLines) { - if (line.trim() === '') continue; - - // Array item - if (line.trim().startsWith('- ')) { - if (currentArray) { - currentArray.push(line.trim().substring(2)); - } - continue; - } - - // Key-value pair - const colonIndex = line.indexOf(':'); - if (colonIndex > 0) { - const key = line.substring(0, colonIndex).trim(); - const value = line.substring(colonIndex + 1).trim(); - - if (value === '') { - // Array start - currentArray = []; - metadata[key] = currentArray; - } else { - // Simple value - metadata[key] = value; - currentArray = null; - } - } - } - - // Validate required fields - if (!metadata.name) { - errors.push('Required field missing: name'); - } else { - // Validate name format (lowercase-hyphenated) - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(metadata.name)) { - errors.push(`Invalid name format: "${metadata.name}". Must be lowercase-hyphenated (e.g., "my-skill-name")`); - } - - // Check name matches directory - const dirName = path.basename(path.dirname(filePath)); - if (dirName !== '.' && dirName !== metadata.name) { - warnings.push(`Skill name "${metadata.name}" does not match directory name "${dirName}"`); - } - - info.push(`Name: ${metadata.name}`); - } - - if (!metadata.description) { - errors.push('Required field missing: description'); - } else { - const descLength = metadata.description.length; - if (descLength < 1 || descLength > 1024) { - errors.push(`Description length (${descLength}) must be between 1 and 1024 characters`); - } - info.push(`Description: ${descLength} characters`); - - // Check for discovery keywords - const keywords = ['when', 'use', 'helps', 'for']; - const hasKeywords = keywords.some(kw => metadata.description.toLowerCase().includes(kw)); - if (!hasKeywords) { - warnings.push('Description should include discovery keywords (when to use this skill) to help agents find it'); - } - } - - // Validate optional fields - if (metadata.version) { - if (!/^\d+\.\d+\.\d+/.test(metadata.version)) { - warnings.push(`Version "${metadata.version}" should follow semver format (e.g., 1.0.0)`); - } - info.push(`Version: ${metadata.version}`); - } - - if (metadata.license) { - info.push(`License: ${metadata.license}`); - } - - if (metadata.author) { - info.push(`Author: ${metadata.author}`); - } - - if (metadata.tags) { - if (Array.isArray(metadata.tags)) { - info.push(`Tags: ${metadata.tags.length} (${metadata.tags.join(', ')})`); - } else { - warnings.push('Tags field should be an array'); - } - } - - if (metadata.compatibility) { - if (Array.isArray(metadata.compatibility)) { - info.push(`Compatibility: ${metadata.compatibility.join(', ')}`); - } else { - warnings.push('Compatibility field should be an array'); - } - } - - // Check for recommended sections - const contentBody = content.substring(frontmatterEnd + 5); - - const recommendedSections = [ - { name: 'When to Use This Skill', pattern: /##\s+When to Use/i }, - { name: 'When NOT to Use This Skill', pattern: /##\s+When NOT to Use/i }, - ]; - - for (const section of recommendedSections) { - if (!section.pattern.test(contentBody)) { - warnings.push(`Recommended section missing: "${section.name}"`); - } - } - - // Check for references directory - const referencesDir = path.join(path.dirname(filePath), 'references'); - if (fs.existsSync(referencesDir)) { - try { - const refFiles = fs.readdirSync(referencesDir); - if (refFiles.length > 0) { - info.push(`References: ${refFiles.length} file(s) (${refFiles.join(', ')})`); - } else { - warnings.push('References directory exists but is empty'); - } - } catch (e) { - // Log with context for debugging, but continue validation - warnings.push(`Could not read references directory: ${e.message}`); - } - } - - // Check for scripts directory - const scriptsDir = path.join(path.dirname(filePath), 'scripts'); - if (fs.existsSync(scriptsDir)) { - try { - const scriptFiles = fs.readdirSync(scriptsDir); - if (scriptFiles.length > 0) { - info.push(`Scripts: ${scriptFiles.length} file(s) (${scriptFiles.join(', ')})`); - } else { - warnings.push('Scripts directory exists but is empty'); - } - } catch (e) { - // Log with context for debugging, but continue validation - warnings.push(`Could not read scripts directory: ${e.message}`); - } - } - - printResults(errors, warnings, info); -} - -function printResults(errors, warnings, info) { - // Print info - if (info.length > 0) { - log('\nℹ Info:', 'blue'); - info.forEach(msg => log(` ${msg}`, 'blue')); - } - - // Print warnings - if (warnings.length > 0) { - log('\n⚠ Warnings:', 'yellow'); - warnings.forEach(msg => log(` ${msg}`, 'yellow')); - } - - // Print errors - if (errors.length > 0) { - log('\nāœ— Errors:', 'red'); - errors.forEach(msg => log(` ${msg}`, 'red')); - log(`\nāŒ Validation FAILED (${errors.length} error(s))\n`, 'red'); - process.exit(1); - } - - if (warnings.length > 0) { - log(`\nāš ļø Validation passed with ${warnings.length} warning(s)\n`, 'yellow'); - } else { - log('\nāœ“ Validation PASSED\n', 'green'); - } -} - -// Main -const args = process.argv.slice(2); -const skillPath = args[0] || 'SKILL.md'; - -// Wrap main execution in try/catch to ensure graceful exit -try { - validateSkillMd(skillPath); -} catch (e) { - log(`\nāœ— Unexpected error during validation: ${e.message}`, 'red'); - if (process.env.DEBUG) { - console.error(e); - } - process.exit(1); -}