diff --git a/scripts/generate_menu_sdef.applescript b/scripts/generate_menu_sdef.applescript
new file mode 100644
index 0000000..71247f6
--- /dev/null
+++ b/scripts/generate_menu_sdef.applescript
@@ -0,0 +1,362 @@
+-- Menu Item SDEF Generator
+-- Prompts for an app, enumerates menu items, and writes an .sdef and plist.
+
+use scripting additions
+
+on run
+ set appName to my promptForAppName()
+ if appName is "" then return
+
+ if my ensureAppRunning(appName) is false then return
+
+ set outputFolder to my promptForOutputFolder()
+ if outputFolder is missing value then return
+
+ set sdefFileName to my promptForText("SDEF file name:", appName & ".sdef")
+ if sdefFileName is "" then return
+
+ set plistFileName to my promptForText("Plist file name:", "ScriptingInfo.plist")
+ if plistFileName is "" then return
+
+ set menuRecords to my fetchMenuItems(appName)
+ if (count of menuRecords) is 0 then
+ display dialog "No menu items found for " & appName buttons {"OK"} default button "OK"
+ return
+ end if
+
+ set sdefText to my buildSdef(appName, menuRecords)
+ set sdefPath to my joinPath(outputFolder, sdefFileName)
+ my writeTextFile(sdefPath, sdefText)
+
+ set bundleId to my getBundleId(appName)
+ set plistText to my buildPlist(appName, bundleId, sdefFileName)
+ set plistPath to my joinPath(outputFolder, plistFileName)
+ my writeTextFile(plistPath, plistText)
+
+ display dialog "Generated:" & linefeed & sdefPath & linefeed & plistPath buttons {"OK"} default button "OK"
+end run
+
+on promptForAppName()
+ repeat
+ try
+ set dialogResult to display dialog "App to scan for menu items:" default answer "Finder" buttons {"Cancel", "OK"} default button "OK"
+ if button returned of dialogResult is "Cancel" then return ""
+ set appName to text returned of dialogResult
+ on error number -128
+ return ""
+ end try
+
+ if appName is "" then return ""
+
+ try
+ set _appAlias to path to application appName
+ return appName
+ on error
+ display dialog "App not found: " & appName buttons {"OK"} default button "OK"
+ end try
+ end repeat
+end promptForAppName
+
+on promptForOutputFolder()
+ try
+ return choose folder with prompt "Choose output folder for the .sdef and .plist files:"
+ on error number -128
+ return missing value
+ end try
+end promptForOutputFolder
+
+on promptForText(promptText, defaultValue)
+ try
+ set dialogResult to display dialog promptText default answer defaultValue buttons {"Cancel", "OK"} default button "OK"
+ if button returned of dialogResult is "Cancel" then return ""
+ return text returned of dialogResult
+ on error number -128
+ return ""
+ end try
+end promptForText
+
+on ensureAppRunning(appName)
+ set isRunning to false
+ tell application "System Events"
+ set isRunning to (exists process appName)
+ end tell
+ if isRunning then return true
+
+ try
+ tell application appName to activate
+ on error errMsg
+ display dialog "Unable to launch " & appName & "." & linefeed & errMsg buttons {"OK"} default button "OK"
+ return false
+ end try
+
+ repeat 20 times
+ delay 0.2
+ tell application "System Events"
+ set isRunning to (exists process appName)
+ end tell
+ if isRunning then return true
+ end repeat
+
+ display dialog appName & " did not become ready in time." buttons {"OK"} default button "OK"
+ return false
+end ensureAppRunning
+
+on fetchMenuItems(appName)
+ set results to {}
+ try
+ tell application "System Events"
+ if not (exists process appName) then return results
+ tell process appName
+ if (count of menu bars) is 0 then return results
+ set menuBarItems to menu bar items of menu bar 1
+ repeat with menuBarItem in menuBarItems
+ set barName to name of menuBarItem
+ try
+ set topMenu to menu of menuBarItem
+ set results to results & my collectMenuItems(topMenu, {barName})
+ end try
+ end repeat
+ end tell
+ end tell
+ on error errMsg number errNum
+ display dialog "Failed to read menus. Ensure Accessibility permissions for System Events." & linefeed & errMsg buttons {"OK"} default button "OK"
+ end try
+ return results
+end fetchMenuItems
+
+on collectMenuItems(menuRef, pathList)
+ set collected to {}
+ set menuItems to menu items of menuRef
+ repeat with menuItem in menuItems
+ set itemName to name of menuItem
+ set itemPath to pathList & {itemName}
+ set itemEnabled to enabled of menuItem
+ set end of collected to {pathList:itemPath, enabled:itemEnabled}
+ if (exists menu of menuItem) then
+ set subMenu to menu of menuItem
+ set collected to collected & my collectMenuItems(subMenu, itemPath)
+ end if
+ end repeat
+ return collected
+end collectMenuItems
+
+on buildSdef(appName, menuRecords)
+ set usedNames to {}
+ set lines to {}
+ set end of lines to ""
+ set end of lines to ""
+ set end of lines to ""
+ set suiteName to "Menu Items"
+ set suiteDesc to "Menu item commands generated from " & appName & "."
+ set end of lines to " "
+
+ set indexNumber to 0
+ repeat with itemRecord in menuRecords
+ set indexNumber to indexNumber + 1
+ set pathList to pathList of itemRecord
+ set sanitizedParts to my sanitizePathList(pathList)
+ set commandBaseName to my joinWith(sanitizedParts, " ")
+ set commandName to commandBaseName
+ set suffixCount to 1
+ repeat while (commandName is in usedNames)
+ set suffixCount to suffixCount + 1
+ set commandName to commandBaseName & " " & suffixCount
+ end repeat
+ set end of usedNames to commandName
+
+ set menuPathString to my joinWith(sanitizedParts, " > ")
+ set eventCode to "MNU1" & my fourCharCode(indexNumber)
+ set descText to "Menu path: " & menuPathString
+
+ set lineText to " "
+ set end of lines to lineText
+ end repeat
+
+ set end of lines to " "
+ set end of lines to ""
+ return my joinLines(lines)
+end buildSdef
+
+on buildPlist(appName, bundleId, sdefFileName)
+ set safeName to my escapeXml(my asciiOnly(appName))
+ set safeBundleId to my escapeXml(my asciiOnly(bundleId))
+ set safeSdef to my escapeXml(my asciiOnly(sdefFileName))
+ set lines to {}
+ set end of lines to ""
+ set end of lines to ""
+ set end of lines to ""
+ set end of lines to ""
+ set end of lines to " CFBundleIdentifier"
+ set end of lines to " " & safeBundleId & ""
+ set end of lines to " CFBundleName"
+ set end of lines to " " & safeName & ""
+ set end of lines to " NSScriptingDefinition"
+ set end of lines to " " & safeSdef & ""
+ set end of lines to ""
+ set end of lines to ""
+ return my joinLines(lines)
+end buildPlist
+
+on getBundleId(appName)
+ try
+ set bundleId to id of application appName
+ if bundleId is missing value then error "missing bundle id"
+ return bundleId as text
+ on error
+ return "com.example." & my slugify(appName)
+ end try
+end getBundleId
+
+on slugify(rawText)
+ set cleaned to my normalizeLabel(rawText)
+ set cleaned to my replaceText(cleaned, " ", "")
+ set cleaned to my replaceText(cleaned, "-", "")
+ set cleaned to my replaceText(cleaned, "_", "")
+ if cleaned is "" then set cleaned to "app"
+ return cleaned
+end slugify
+
+on sanitizePathList(pathList)
+ set sanitizedParts to {}
+ repeat with partItem in pathList
+ set safePart to my normalizeLabel(partItem)
+ if safePart is "" then set safePart to "Menu"
+ set end of sanitizedParts to safePart
+ end repeat
+ return sanitizedParts
+end sanitizePathList
+
+on normalizeLabel(rawText)
+ set cleaned to my asciiOnly(rawText)
+ set cleaned to my replaceText(cleaned, ":", " ")
+ set cleaned to my replaceText(cleaned, "/", " ")
+ set cleaned to my replaceText(cleaned, "&", " and ")
+ set cleaned to my replaceText(cleaned, ">", " ")
+ set cleaned to my replaceText(cleaned, "<", " ")
+ set cleaned to my replaceText(cleaned, "?", " ")
+ set cleaned to my replaceText(cleaned, "*", " ")
+ set cleaned to my replaceText(cleaned, quote, "")
+ set cleaned to my replaceText(cleaned, "'", "")
+ set cleaned to my collapseWhitespace(cleaned)
+ if cleaned is "" or cleaned is "-" then set cleaned to "Separator"
+ return cleaned
+end normalizeLabel
+
+on asciiOnly(inputText)
+ set outputText to ""
+ set charCount to count of characters of inputText
+ repeat with i from 1 to charCount
+ set ch to character i of inputText
+ set chCode to ASCII number of ch
+ if chCode is 9 then
+ set outputText to outputText & " "
+ else if chCode >= 32 and chCode <= 126 then
+ set outputText to outputText & ch
+ else if chCode is 8230 then
+ set outputText to outputText & "..."
+ else if chCode is 8216 or chCode is 8217 then
+ set outputText to outputText & "'"
+ else if chCode is 8220 or chCode is 8221 then
+ set outputText to outputText & quote
+ else if chCode is 8211 or chCode is 8212 then
+ set outputText to outputText & "-"
+ else
+ set outputText to outputText & " "
+ end if
+ end repeat
+ return outputText
+end asciiOnly
+
+on collapseWhitespace(inputText)
+ set AppleScript's text item delimiters to {space, tab, return, linefeed}
+ set parts to text items of inputText
+ set AppleScript's text item delimiters to space
+ set joined to parts as text
+ set AppleScript's text item delimiters to ""
+ return my trimSpaces(joined)
+end collapseWhitespace
+
+on trimSpaces(inputText)
+ set outputText to inputText
+ repeat while outputText begins with " "
+ if (count of characters of outputText) is 1 then
+ set outputText to ""
+ else
+ set outputText to text 2 thru -1 of outputText
+ end if
+ end repeat
+ repeat while outputText ends with " "
+ if (count of characters of outputText) is 1 then
+ set outputText to ""
+ else
+ set outputText to text 1 thru -2 of outputText
+ end if
+ end repeat
+ return outputText
+end trimSpaces
+
+on escapeXml(inputText)
+ set escapedText to inputText
+ set escapedText to my replaceText(escapedText, "&", "&")
+ set escapedText to my replaceText(escapedText, "<", "<")
+ set escapedText to my replaceText(escapedText, ">", ">")
+ set escapedText to my replaceText(escapedText, quote, """)
+ set escapedText to my replaceText(escapedText, "'", "'")
+ return escapedText
+end escapeXml
+
+on joinWith(itemList, separatorText)
+ set AppleScript's text item delimiters to separatorText
+ set joinedText to itemList as text
+ set AppleScript's text item delimiters to ""
+ return joinedText
+end joinWith
+
+on joinLines(lineList)
+ set AppleScript's text item delimiters to linefeed
+ set joinedText to lineList as text
+ set AppleScript's text item delimiters to ""
+ return joinedText
+end joinLines
+
+on replaceText(inputText, findText, replaceWith)
+ set AppleScript's text item delimiters to findText
+ set parts to text items of inputText
+ set AppleScript's text item delimiters to replaceWith
+ set outputText to parts as text
+ set AppleScript's text item delimiters to ""
+ return outputText
+end replaceText
+
+on writeTextFile(filePath, fileContents)
+ set fileRef to open for access (POSIX file filePath) with write permission
+ try
+ set eof of fileRef to 0
+ write fileContents to fileRef starting at eof
+ close access fileRef
+ on error errMsg number errNum
+ try
+ close access fileRef
+ end try
+ error errMsg number errNum
+ end try
+end writeTextFile
+
+on joinPath(folderAlias, fileName)
+ set folderPath to POSIX path of folderAlias
+ if folderPath does not end with "/" then set folderPath to folderPath & "/"
+ return folderPath & fileName
+end joinPath
+
+on fourCharCode(indexNumber)
+ set alphabet to "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ set base to 26
+ set n to indexNumber - 1
+ set codeText to ""
+ repeat 4 times
+ set charIndex to (n mod base) + 1
+ set codeText to (character charIndex of alphabet) & codeText
+ set n to n div base
+ end repeat
+ return codeText
+end fourCharCode