Your MacBook should be working for you and not the other way around. So it is essential to utilize the capabilities you have in your hands locally by automating your tasks and repetative workflows as soon as possible.
For This case, We will be autmating the generation of custom Cover Letters that you send to companies along with your CV/Resume. Since the Cover Letter is tailored to a specific company, job position and date. The proccess of copying a Cover Letter, editing those fields, then saving the file as PDF is a long process when you apply to tens of job postings.
Overview
We will create a Bash and a JavaScript script to customize and generate the Cover Letter as PDF. Then we will add this script to raycast to be called through their GUI.
Note: We are forced to use the JS script because when I updated the .docx file with only built-in tools, the generated pdf either failed or removed all the original document styles.
Requirements to setup
- Raycast installed.
- NodeJS installed.
- Cover Letter as Word Document or Google Docs. If Google Docs, download it as Microsoft Word (.docx) locally.
Setup
To keep everything organized and ready for future work, we will create a folder in our root directory called raycast-scripts/cover-letter then change directory to it.
cd ~
mkdir raycast-scripts/cover-letter
cd raycast-scripts/cover-letter
Next we need to install the required packages to manipulate the docx file in the next step.
npm i pizzip docxtemplater
Creating the Scripts
Create two files: cover-letter-local.sh and generate-cover-letter.mjs. Make the shell script executable:
chmod +x cover-letter-local.sh
1. cover-letter-local.sh
This script is the entry point that Raycast will call. It accepts Company and Role as arguments, locates your DOCX template, runs the Node script to fill placeholders, then uses Pages (via AppleScript) to export the filled document to PDF.
#!/bin/bash
# @raycast.schemaVersion 1
# @raycast.title Generate Cover Letter
# @raycast.mode compact
# @raycast.packageName cover-letter
# @raycast.icon 📄
# @raycast.argument1 { "type": "text", "placeholder": "Company (e.g. Acme)" }
# @raycast.argument2 { "type": "text", "placeholder": "Role (e.g. Backend Engineer)" }
set -euo pipefail
COMPANY_NAME="$1"
ROLE_NAME="$2"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TEMPLATE_DOCX="$SCRIPT_DIR/JobSearch/YOUR_COVER_LETTER_FILE_NAME.docx"
OUTDIR="$HOME/Downloads/Cover Letters"
mkdir -p "$OUTDIR"
NEW_DATE="$(LC_TIME=en_US_POSIX date '+%b %e, %Y' | sed 's/ / /g')"
sanitize() {
echo "$1" \
| tr '\n' ' ' \
| sed -E 's/[\/:*?"<>|]/-/g; s/[[:space:]]+/ /g; s/^[[:space:]]+//; s/[[:space:]]+$//'
}
SAFE_COMPANY="$(sanitize "$COMPANY_NAME")"
BASENAME="YOUR_NAME_${SAFE_COMPANY}_Cover Letter"
TMPDOCX="/tmp/${BASENAME}.docx"
PDFPATH="${OUTDIR}/${BASENAME}.pdf"
if [[ ! -f "$TEMPLATE_DOCX" ]]; then
echo "DOCX template not found: $TEMPLATE_DOCX"
exit 1
fi
if [[ ! -d "$SCRIPT_DIR/node_modules" ]]; then
echo "Run once in this folder: npm install"
exit 1
fi
NODE=""
if command -v node &>/dev/null; then
NODE="node"
elif [[ -x "$HOME/.nvm/current/bin/node" ]]; then
NODE="$HOME/.nvm/current/bin/node"
elif [[ -x /opt/homebrew/bin/node ]]; then
NODE="/opt/homebrew/bin/node"
elif [[ -x /usr/local/bin/node ]]; then
NODE="/usr/local/bin/node"
fi
if [[ -z "$NODE" ]]; then
echo "node not found. Install Node.js or add it to PATH."
exit 1
fi
"$NODE" "$SCRIPT_DIR/generate-cover-letter.mjs" \
"$TEMPLATE_DOCX" "$TMPDOCX" "$COMPANY_NAME" "$ROLE_NAME" "$NEW_DATE"
osascript - "$TMPDOCX" "$PDFPATH" <<'APPLESCRIPT'
on run argv
set docxPosix to item 1 of argv
set pdfPosix to item 2 of argv
set docxAlias to (POSIX file docxPosix) as alias
set pdfFile to POSIX file pdfPosix
tell application "Pages"
activate
open docxAlias
delay 0.8
tell front document
export it to pdfFile as PDF
close saving no
end tell
quit
end tell
end run
APPLESCRIPT
rm -f "$TMPDOCX"
echo "Saved: $PDFPATH"
What it does:
- Raycast metadata (the
@raycast.*comments) define the command name, icon, and two text arguments so Raycast shows “Company” and “Role” fields. - Paths:
TEMPLATE_DOCXusesYOUR_COVER_LETTER_FILE_NAME.docxinside aJobSearch/subfolder. ReplaceYOUR_COVER_LETTER_FILE_NAMEwith your actual DOCX filename (without the path). PDFs are written to~/Downloads/Cover Letters. - Date: Uses the current date in a format like
Feb 11, 2025. - Sanitize: Cleans the company name for use in the filename (removes or replaces characters that are invalid in filenames).
- Naming:
BASENAMEusesYOUR_NAME_so your files look likeYOUR_NAME_Acme_Cover Letter.pdf. ReplaceYOUR_NAME_with your real name or initials. - Node lookup: Tries
nodeinPATH, then common install locations (nvm, Homebrew), so the script works when launched from Raycast (wherePATHmay be minimal). - AppleScript: Opens the filled DOCX in Pages, exports it to PDF, closes the document without saving, then quits Pages. The temporary DOCX is deleted.
2. generate-cover-letter.mjs
This Node script fills the DOCX template by replacing plain-text placeholders inside the document XML. It does not use {COMPANY_NAME} syntax, just the words COMPANY_NAME, ROLE_NAME, and NEW_DATE in your Word file.
#!/usr/bin/env node
/**
* Usage: node generate-cover-letter.mjs <template.docx> <output.docx> <company> <role> <date>
*/
import PizZip from "pizzip";
import fs from "fs";
import path from "path";
const [templatePath, outputPath, companyName, roleName, newDate] =
process.argv.slice(2);
const TEMPLATE = templatePath || process.env.TEMPLATE_PATH;
const OUTPUT = outputPath || process.env.OUTPUT_PATH;
const COMPANY = companyName || process.env.COMPANY_NAME || "";
const ROLE = roleName || process.env.ROLE_NAME || "";
const DATE = newDate || process.env.NEW_DATE || "";
if (!TEMPLATE || !OUTPUT) {
console.error(
"Usage: node generate-cover-letter.mjs <template.docx> <output.docx> <company> <role> <date>",
);
process.exit(1);
}
const templateAbs = path.resolve(TEMPLATE);
const outputAbs = path.resolve(OUTPUT);
if (!fs.existsSync(templateAbs)) {
console.error("Template not found:", templateAbs);
process.exit(1);
}
const content = fs.readFileSync(templateAbs, "binary");
const zip = new PizZip(content);
const docPath = "word/document.xml";
const docFile = zip.files[docPath];
if (!docFile) {
console.error("DOCX invalid: missing word/document.xml");
process.exit(1);
}
let xml = docFile.asText();
const replacements = [
["COMPANY_NAME", COMPANY],
["ROLE_NAME", ROLE],
["NEW_DATE", DATE],
];
for (const [placeholder, value] of replacements) {
const escaped = String(value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
xml = xml.split(placeholder).join(escaped);
}
zip.file(docPath, xml);
const buf = zip.generate({ type: "nodebuffer" });
fs.writeFileSync(outputAbs, buf);
console.log(outputAbs);
What it does:
- Reads the DOCX as a ZIP, edits
word/document.xml, and writes a new DOCX. All formatting (styles, fonts, layout) is preserved because only text nodes are changed. - Replaces COMPANY_NAME, ROLE_NAME, and NEW_DATE with the values passed in (or from env). Values are XML-escaped so characters like
&or<do not break the file.
3. Prepare your DOCX template
- Write your cover letter in Word (or Google Docs, then File → Download → Microsoft Word (.docx)).
- Where you want the company name, type COMPANY_NAME (no curly braces).
- Where you want the job title, type ROLE_NAME.
- Where you want the date, type NEW_DATE.
- Save the file and put it where
cover-letter-local.shexpects it, for exampleraycast-scripts/cover-letter/JobSearch/YOUR_COVER_LETTER_FILE_NAME.docx. In the script, replaceYOUR_COVER_LETTER_FILE_NAMEwith your actual DOCX filename andYOUR_NAME_with your name (or initials) so the exported files have the name you want.
4. Add the script to Raycast
- Open Raycast → Settings → Extensions.
- Click Add Script Directory and choose
~/raycast-scripts/cover-letter(or the folder wherecover-letter-local.shandgenerate-cover-letter.mjslive). - Raycast will discover the script from the
@raycast.*comments. You should see Generate Cover Letter under the package name cover-letter. - Run it: enter a company and role, press Enter. The script will generate the filled DOCX, export it to PDF via Pages, and save it in
~/Downloads/Cover Letters.
You now have a one-step flow: Company + Role in Raycast → tailored cover letter PDF on your Mac, with no manual find-and-replace or export steps.
I hope you land this job!