skills/script-refactorer/SKILL.md
Modernizes legacy scripts (bash, Node, Python) by replacing deprecated APIs, adding error handling, converting callbacks to async/await, and improving maintainability. Turns brittle one-off scripts into reliable, documented tools.
npx skillsauth add curiositech/windags-skills script-refactorerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Modernizes legacy scripts into reliable, maintainable tools. Replaces deprecated patterns, adds error handling and input validation, converts callback spaghetti to async/await, and turns undocumented one-off hacks into proper CLI tools.
Use this skill when:
new Buffer(), url.parse(), fs.exists())set -euo pipefail and proper quotingDo NOT use this skill for:
Before -- the kind of script that makes you wince:
#!/bin/bash
# deploy.sh - "works on my machine"
cd /opt/app
git pull
npm install
npm run build
pm2 restart app
echo "done"
After -- a script you can trust:
#!/usr/bin/env bash
set -euo pipefail
# deploy.sh — Deploy application with rollback safety
# Usage: deploy.sh [--branch <branch>] [--skip-build] [--dry-run]
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly APP_DIR="/opt/app"
readonly LOG_FILE="/var/log/deploy-$(date +%Y%m%d-%H%M%S).log"
# Defaults
BRANCH="main"
SKIP_BUILD=false
DRY_RUN=false
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Deploy the application from git with rollback safety.
Options:
--branch <branch> Branch to deploy (default: main)
--skip-build Skip npm install and build steps
--dry-run Show what would happen without doing it
-h, --help Show this help message
Environment:
APP_DIR Application directory (default: /opt/app)
PM2_APP_NAME PM2 process name (default: app)
EOF
exit 0
}
log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
die() { log "FATAL: $*" >&2; exit 1; }
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--branch) BRANCH="${2:?--branch requires a value}"; shift 2 ;;
--skip-build) SKIP_BUILD=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
-h|--help) usage ;;
*) die "Unknown option: $1" ;;
esac
done
}
preflight_checks() {
command -v git >/dev/null 2>&1 || die "git is not installed"
command -v npm >/dev/null 2>&1 || die "npm is not installed"
command -v pm2 >/dev/null 2>&1 || die "pm2 is not installed"
[[ -d "$APP_DIR/.git" ]] || die "$APP_DIR is not a git repository"
}
main() {
parse_args "$@"
preflight_checks
local prev_commit
prev_commit=$(git -C "$APP_DIR" rev-parse HEAD)
log "Current commit: ${prev_commit:0:8}"
log "Deploying branch: $BRANCH"
if $DRY_RUN; then
log "DRY RUN — would deploy $BRANCH to $APP_DIR"
exit 0
fi
# Capture state for rollback
trap 'log "Deploy failed. Rolling back to ${prev_commit:0:8}"; git -C "$APP_DIR" checkout "$prev_commit" 2>/dev/null' ERR
git -C "$APP_DIR" fetch origin "$BRANCH"
git -C "$APP_DIR" checkout "$BRANCH"
git -C "$APP_DIR" pull origin "$BRANCH"
if ! $SKIP_BUILD; then
log "Installing dependencies..."
(cd "$APP_DIR" && npm ci --production)
log "Building..."
(cd "$APP_DIR" && npm run build)
fi
log "Restarting application..."
pm2 restart "${PM2_APP_NAME:-app}"
log "Deploy complete: $(git -C "$APP_DIR" rev-parse --short HEAD)"
}
main "$@"
Key transformations:
set -euo pipefail -- fail on errors, undefined variables, pipe failures#!/usr/bin/env bash -- portable shebang--help| Deprecated | Replacement | Since |
|------------|-------------|-------|
| new Buffer(data) | Buffer.from(data) | Node 6 |
| Buffer(size) | Buffer.alloc(size) | Node 6 |
| url.parse(str) | new URL(str) | Node 10 |
| url.resolve(from, to) | new URL(to, from).href | Node 10 |
| fs.exists(path, cb) | fs.access(path) or fs.stat(path) | Node 1 |
| path.exists() | fs.existsSync() | Node 1 |
| require('domain') | AsyncLocalStorage | Node 13 |
| crypto.createCipher() | crypto.createCipheriv() | Node 10 |
| process.binding() | N/A (internal only) | Node 10 |
| util.puts/print/debug | console.log/error | Node 0.12 |
| sys module | util module | Ancient |
| querystring.parse() | URLSearchParams | Node 10 |
| os.tmpDir() | os.tmpdir() | Node 4 |
Before -- callback pyramid:
const fs = require('fs');
const path = require('path');
function processFiles(dir, callback) {
fs.readdir(dir, function(err, files) {
if (err) return callback(err);
let results = [];
let pending = files.length;
if (!pending) return callback(null, results);
files.forEach(function(file) {
const filePath = path.join(dir, file);
fs.stat(filePath, function(err, stat) {
if (err) return callback(err);
if (stat.isDirectory()) {
processFiles(filePath, function(err, res) {
if (err) return callback(err);
results = results.concat(res);
if (!--pending) callback(null, results);
});
} else {
fs.readFile(filePath, 'utf8', function(err, content) {
if (err) return callback(err);
results.push({ path: filePath, content: content });
if (!--pending) callback(null, results);
});
}
});
});
});
}
After -- clean async/await:
import { readdir, stat, readFile } from 'node:fs/promises';
import { join } from 'node:path';
async function processFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const results = await Promise.all(
entries.map(async (entry) => {
const filePath = join(dir, entry.name);
if (entry.isDirectory()) {
return processFiles(filePath);
}
const content = await readFile(filePath, 'utf8');
return [{ path: filePath, content }];
})
);
return results.flat();
}
Conversion checklist:
require() with import (ESM) or keep require if CommonJS is requirednode: prefix for built-in modules (node:fs, node:path)fs.readFile(path, cb) with await readFile(path) from node:fs/promisesPromise.all() or Promise.allSettled()try/catch around callbacks with try/catch around awaitfor await...of for streams instead of .on('data', cb){ withFileTypes: true } to readdir to avoid extra stat callsCommon upgrades:
# BEFORE: Python 2/early 3 patterns
import os
import sys
import json
# String formatting
msg = "Hello %s, you have %d items" % (name, count)
# File handling
f = open('data.json', 'r')
data = json.load(f)
f.close()
# Path construction
config_path = os.path.join(os.path.dirname(__file__), '..', 'config', 'settings.json')
# Subprocess
import subprocess
output = subprocess.Popen(['ls', '-la'], stdout=subprocess.PIPE).communicate()[0]
# Type checking
if type(x) == str:
pass
# AFTER: Modern Python 3.10+
from pathlib import Path
import json
import subprocess
import sys
# f-strings
msg = f"Hello {name}, you have {count} items"
# Context manager
with Path('data.json').open() as f:
data = json.load(f)
# pathlib
config_path = Path(__file__).parent.parent / 'config' / 'settings.json'
# subprocess.run
result = subprocess.run(['ls', '-la'], capture_output=True, text=True, check=True)
output = result.stdout
# isinstance
if isinstance(x, str):
pass
# Bash: proper signal handling
cleanup() {
log "Caught signal, cleaning up..."
rm -f "$LOCK_FILE"
exit 130 # 128 + SIGINT(2)
}
trap cleanup INT TERM
# Exit code conventions
# 0 = success
# 1 = general error
# 2 = misuse of shell command (bad args)
# 126 = command not executable
# 127 = command not found
# 130 = terminated by Ctrl+C (128+2)
# 143 = terminated by SIGTERM (128+15)
// Node.js: graceful shutdown
process.on('SIGINT', async () => {
console.log('\nReceived SIGINT. Cleaning up...');
await cleanup();
process.exit(130);
});
process.on('SIGTERM', async () => {
console.log('Received SIGTERM. Shutting down gracefully...');
await cleanup();
process.exit(143);
});
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err.message);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
process.exit(1);
});
| Signal | Action | |--------|--------| | Script is < 100 lines and basically works | Refactor in place | | Script works but has no error handling | Add error handling, keep logic | | Script uses APIs deprecated 2+ major versions ago | Replace deprecated APIs | | Script is > 300 lines with tangled control flow | Consider splitting into modules first | | Script is in a dead language/runtime | Rewrite (but that's a different skill) | | Script is a critical production tool | Refactor incrementally with tests |
type: "module" in package.json, libraries targeting modern consumersrequire() conditionally, tools with no plans to publish as packages| Language | Recommended | Why |
|----------|-------------|-----|
| Bash | Manual case loop or getopts | No dependencies, portable |
| Node.js | parseArgs (built-in since Node 18.3) | Zero dependencies |
| Python | argparse (stdlib) | Battle-tested, auto-generates help |
| Go | flag (stdlib) or cobra | Standard practice |
Symptom: Adding full argument parsing, logging, and tests to a one-time data migration script Fix: If the script runs once and is deleted, minimal error handling is enough. Save the polish for scripts that live in the repo.
bash with Node.js for file operationsSymptom: Rewriting cp, mv, find, grep in JavaScript
Fix: Shell is the right tool for file manipulation. Only move to Node.js when the logic (not the I/O) is complex.
Symptom: Converting a .js script to .ts and calling it "modernized" Fix: Types catch shape errors. Tests catch logic errors. You need both.
Symptom: Renaming flags, changing exit codes, or altering output format during a refactor Fix: Refactoring means behavior-preserving changes. If callers depend on the interface, maintain backward compatibility or coordinate the migration.
Symptom: #!/bin/bash on a script that uses bash 4+ features, deployed to macOS (bash 3.2)
Fix: Use #!/usr/bin/env bash and test on all target platforms. If you need bash 4+, document it.
Symptom: cat file.txt | grep pattern | wc -l where file.txt doesn't exist and the script continues
Fix: set -o pipefail in bash. In Node.js, check process.exitCode after spawning child processes.
Symptom: "While I'm in here, let me also add support for..." Fix: One commit for the refactor (behavior-preserving). A separate commit for new features. This keeps the refactor reviewable and revertable.
--help)--help flag works and shows all optionsset -euo pipefail is present (bash scripts)data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.