Skip to content

Instantly share code, notes, and snippets.

@szhu
Created January 27, 2026 18:20
Show Gist options
  • Select an option

  • Save szhu/0a0e432c733e76ff2dc375b392d5f315 to your computer and use it in GitHub Desktop.

Select an option

Save szhu/0a0e432c733e76ff2dc375b392d5f315 to your computer and use it in GitHub Desktop.
Programmatically edit macOS text subsitutions
#!/usr/bin/env node
const { execSync } = require("child_process");
const path = require("path");
const plist = path.join(
process.env.HOME,
"Library/Preferences/.GlobalPreferences.plist",
);
const key = "NSUserDictionaryReplacementItems";
function escapeShell(str) {
if (str === null || str === undefined) {
return "";
}
return str.replace(/'/g, "'\\''");
}
function add(key, value) {
if (!key || !value) {
console.error(`Usage: textsub add "@shortcut" "Full Phrase"`);
return 1;
}
// Identify the existing index.
const findTargetIndex = () => {
try {
const json = execSync(
`plutil -extract "${key}" json -o - "${plist}" 2>/dev/null`,
{ encoding: "utf8" },
);
const rules = JSON.parse(json);
const index = rules.findIndex((r) => r.replace === key);
return index === -1 ? null : index;
} catch {
return null;
}
};
const targetIndex = findTargetIndex();
// Remove the existing index.
if (targetIndex != null) {
console.log(
`Removing existing entry for "${key}" at index ${targetIndex}.`,
);
execSync(`plutil -remove "${key}.${targetIndex}" "${plist}"`);
}
// Append new entry.
console.log(`Appending "${key}" -> "${value}".`);
const entry = `{on=1;replace='${escapeShell(key)}';with='${escapeShell(
value,
)}';}`;
execSync(`defaults write -g "${key}" -array-add "${entry}"`);
// Verify.
console.log(`Added:`);
execSync(`defaults read -g "${key}" | tail -5 | head -3`, {
stdio: "inherit",
});
try {
execSync("killall AppleSpell TextInputMenuAgent 2>/dev/null || true");
console.log("Text services refreshed.");
} catch {
// Ignore errors if processes weren't running.
}
return 0;
}
function list(...args) {
const jsonOutput = args.includes("--json");
try {
const json = execSync(
`plutil -extract "${key}" json -o - "${plist}" 2>/dev/null`,
{ encoding: "utf8" },
);
const rules = JSON.parse(json);
rules.sort((a, b) => a.replace.localeCompare(b.replace));
for (const rule of rules) {
if (jsonOutput) {
console.log(
`${JSON.stringify(rule.replace)}\n\t${JSON.stringify(rule.with)}`,
);
} else {
console.log(`${rule.replace}\n\t${rule.with}`);
}
}
} catch (error) {
console.error(`Error in list subcommand: ${error.message}`);
return 1; // Indicate an error occurred.
}
return 0;
}
function remove(key) {
if (!key) {
console.error(`Usage: textsub remove "@shortcut"`);
return 1;
}
const findTargetIndex = () => {
try {
const json = execSync(
`plutil -extract "${key}" json -o - "${plist}" 2>/dev/null`,
{ encoding: "utf8" },
);
const rules = JSON.parse(json);
const index = rules.findIndex((r) => r.replace === key);
return index === -1 ? null : index;
} catch {
return null;
}
};
const targetIndex = findTargetIndex();
if (targetIndex != null) {
console.log(
`Removing existing entry for "${key}" at index ${targetIndex}.`,
);
execSync(`plutil -remove "${key}.${targetIndex}" "${plist}"`);
try {
execSync("killall AppleSpell TextInputMenuAgent 2>/dev/null || true");
console.log("Text services refreshed.");
} catch {
// Ignore errors if processes weren't running.
}
} else {
console.error(`No entry found for "${key}".`);
return 1;
}
return 0;
}
function main() {
const [, , subcommand, ...args] = process.argv;
let exitCode = 0;
switch (subcommand) {
case "add":
exitCode = add(...args);
break;
case "list":
exitCode = list(...args);
break;
case "remove":
exitCode = remove(...args);
break;
default:
console.error(`Unknown subcommand: ${subcommand}`);
console.error(`Usage: textsub <add|list [--json]|remove>`);
exitCode = 1;
}
process.exit(exitCode);
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment