Created
January 27, 2026 18:20
-
-
Save szhu/0a0e432c733e76ff2dc375b392d5f315 to your computer and use it in GitHub Desktop.
Programmatically edit macOS text subsitutions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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