Skip to content

Instantly share code, notes, and snippets.

@sdkks
Last active April 30, 2026 19:40
Show Gist options
  • Select an option

  • Save sdkks/3bf107f461c0006528cd3b75cb20b46c to your computer and use it in GitHub Desktop.

Select an option

Save sdkks/3bf107f461c0006528cd3b75cb20b46c to your computer and use it in GitHub Desktop.
How to edit a file with Neovim+iTerm2 on MacOS

Finder → iTerm2 + Neovim handler

Double-clicking a source or config file in Finder opens it in Neovim inside a new iTerm2 window. Selecting multiple files opens them as tabs in a single window (nvim -p).

Requirements

  • iTerm2
  • Neovim (nvim on $PATH)
  • duti: brew install duti

Usage

sudo rm -rf /Applications/NeovimLauncher.app   # remove old build if rebuilding
bash build-neovimlauncher.sh
bash set-handlers.sh

On first file open, macOS will prompt "NeovimLauncher wants access to control iTerm2" — click Allow. This is a one-time TCC permission grant.

Files

File Purpose
build-neovimlauncher.sh Builds /Applications/NeovimLauncher.app and registers it
set-handlers.sh Runs duti to assign every extension to the app

How it works

Why a .app bundle is required

macOS Launch Services — the subsystem that handles double-clicks in Finder — only routes files to registered .app bundles. It sends an Apple Event (odoc) to the target application; it does not pass file paths as CLI arguments. A plain shell script can never receive this event. The bundle is the required wrapper.

osacompile — AppleScript as the executable

Rather than writing a raw shell script as the bundle executable (which cannot receive Apple Events), osacompile compiles an AppleScript into a native applet and wraps it in a complete .app bundle in one step:

osacompile -o NeovimLauncher.app - <<'APPLESCRIPT'
...
APPLESCRIPT

The compiled applet binary (Contents/MacOS/droplet) is a real Mach-O binary that the OS can launch, and it natively handles the on open theFiles Apple Event handler.

on open theFiles — the correct handler

on open theFiles
    repeat with f in theFiles
        set allPaths to allPaths & " " & quoted form of POSIX path of f
    end repeat
    ...
end open

on open is the AppleScript handler invoked by Launch Services when a file is opened with the app. Each element of theFiles is a file alias; POSIX path of f converts it to a Unix path string. quoted form of shell-escapes it, handling spaces and special characters safely.

All files are aggregated into a single nvim -p file1 file2 ... command so multiple files selected in Finder open as tabs in one window rather than spawning multiple iTerm2 windows.

Info.plist — declaring handled UTIs

osacompile generates a wildcard CFBundleDocumentTypes that accepts *.*. We replace it via PlistBuddy with a specific list of UTIs:

UTI Covers
public.plain-text .txt, .md, and anything conforming to plain text
public.source-code All programming language files
public.data Extensionless files: Dockerfile, .env, .gitignore
public.json .json, .jsonl
public.xml .xml, .plist
net.daringfireball.markdown .md, .markdown

UTIs are hierarchical — registering public.source-code also catches every UTI that conforms to it (Python, Swift, JS, etc.) without listing each one explicitly. The explicit entries above are for types that sit outside that hierarchy (JSON, XML, Markdown).

PlistBuddy patches the plist in-place rather than overwriting it, preserving the keys osacompile emits that the applet runtime depends on (e.g. LSRequiresCarbon, OSAAppletShowStartupScreen).

Signing and quarantine

macOS refuses to run unsigned or quarantined app bundles with a "damaged or incomplete" dialog. Two steps fix this:

codesign --force --deep --sign -   # ad-hoc signature — no Apple Developer account needed
xattr -dr com.apple.quarantine     # clear the quarantine flag set on newly created bundles

An ad-hoc signature (-) satisfies the bundle integrity check without requiring a paid Apple Developer certificate.

Launch Services registration

lsregister -r -domain local -domain system -domain user

Forces macOS to rescan /Applications and update the UTI→app mapping database. Without this, the new bundle may not appear in "Open With" menus or be resolved by duti until the next login.

duti — forcing default associations

lsregister makes the app available as a handler. duti makes it the default:

duti -s com.custom.neovimlauncher .json all

The all role covers viewer, editor, and shell. set-handlers.sh runs this for every relevant extension. Verify any association with:

duti -x json

TCC — Apple Events permission

When the app first tells iTerm2 to do something via AppleScript, macOS intercepts the Apple Event and shows a consent prompt. If you click Don't Allow, the permission is permanently denied and subsequent attempts fail silently. Reset it with:

tccutil reset AppleEvents com.custom.neovimlauncher

build-neovimlauncher.sh offers to run this automatically at the end of the build in case you need to re-grant after a denied prompt.

Troubleshooting: stubborn files opening in the wrong app

If a specific file keeps opening in another app despite duti being set, it has a per-file override stored as an extended attribute:

xattr -d com.apple.LaunchServices.OpenWith /path/to/file

This is set when you use "Open With" on a single file without clicking "Change All". Removing it causes the file to fall back to the global Launch Services rule.

#!/usr/bin/env bash
# build-neovimlauncher.sh — creates /Applications/NeovimLauncher.app
set -euo pipefail
APP_NAME="NeovimLauncher"
APP_DIR="/Applications/${APP_NAME}.app"
BUNDLE_ID="com.custom.neovimlauncher"
# ── 1. Compile AppleScript directly into the .app bundle ──────────────────
osacompile -o "${APP_DIR}" - <<'APPLESCRIPT'
on open theFiles
set myEditor to "nvim"
set allPaths to ""
repeat with f in theFiles
set allPaths to allPaths & " " & quoted form of POSIX path of f
end repeat
set myCmd to myEditor & " -p" & allPaths & " && exit"
tell application "iTerm2"
activate
create window with default profile
tell current session of current window
write text myCmd
end tell
end tell
end open
on run
end run
APPLESCRIPT
# ── 3. Patch the osacompile-generated Info.plist (preserve existing keys) ─
PLIST="${APP_DIR}/Contents/Info.plist"
PB="/usr/libexec/PlistBuddy"
# CFBundleIdentifier is absent in osacompile output — Add only
$PB -c "Add :CFBundleIdentifier string ${BUNDLE_ID}" "${PLIST}"
# CFBundleName already exists — Set only
$PB -c "Set :CFBundleName ${APP_NAME}" "${PLIST}"
# Replace the wildcard CFBundleDocumentTypes osacompile generates
$PB -c "Delete :CFBundleDocumentTypes" "${PLIST}"
$PB -c "Add :CFBundleDocumentTypes array" "${PLIST}"
$PB -c "Add :CFBundleDocumentTypes:0 dict" "${PLIST}"
$PB -c "Add :CFBundleDocumentTypes:0:CFBundleTypeRole string Editor" "${PLIST}"
$PB -c "Add :CFBundleDocumentTypes:0:LSHandlerRank string Alternate" "${PLIST}"
$PB -c "Add :CFBundleDocumentTypes:0:LSItemContentTypes array" "${PLIST}"
$PB -c "Add :CFBundleDocumentTypes:0:LSItemContentTypes:0 string public.plain-text" "${PLIST}"
$PB -c "Add :CFBundleDocumentTypes:0:LSItemContentTypes:1 string public.source-code" "${PLIST}"
$PB -c "Add :CFBundleDocumentTypes:0:LSItemContentTypes:2 string public.data" "${PLIST}"
$PB -c "Add :CFBundleDocumentTypes:0:LSItemContentTypes:3 string public.json" "${PLIST}"
$PB -c "Add :CFBundleDocumentTypes:0:LSItemContentTypes:4 string public.xml" "${PLIST}"
$PB -c "Add :CFBundleDocumentTypes:0:LSItemContentTypes:5 string net.daringfireball.markdown" "${PLIST}"
# ── 4. Ad-hoc sign and clear quarantine ───────────────────────────────────
codesign --force --deep --sign - "${APP_DIR}"
xattr -dr com.apple.quarantine "${APP_DIR}"
# ── 5. Register with Launch Services ──────────────────────────────────────
/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister \
-r -domain local -domain system -domain user
echo "Built ${APP_DIR}"
echo "Bundle ID: ${BUNDLE_ID}"
echo ""
# ── 6. Optionally reset TCC Apple Events permission ───────────────────────
echo "Reset TCC Apple Events permission for ${BUNDLE_ID}?"
echo "Do this if you previously denied the iTerm2 control prompt. [y/N]"
read -r RESET_TCCUTIL
if [[ "${RESET_TCCUTIL}" =~ ^[Yy]$ ]]; then
tccutil reset AppleEvents "${BUNDLE_ID}"
echo "TCC reset. You will be prompted to Allow on next launch."
fi
echo ""
echo "Now run set-handlers.sh to assign extensions via duti."
#!/usr/bin/env bash
# set-handlers.sh — assign com.custom.neovimlauncher as the default handler
# for source code and config/data file extensions via duti.
# Requires: duti (brew install duti)
set -euo pipefail
APP="com.custom.neovimlauncher"
extensions=(
# ── C / C++ / Objective-C ────────────────────────────────────────────────
c h cpp cc cxx hpp m mm
# ── Python ──────────────────────────────────────────────────────────────
py pyw
# ── JavaScript / TypeScript ──────────────────────────────────────────────
js jsx mjs cjs ts tsx
# ── Web (CSS family) ─────────────────────────────────────────────────────
css scss sass less
# ── JVM languages ────────────────────────────────────────────────────────
java kt kts scala groovy clj
# ── Ruby ─────────────────────────────────────────────────────────────────
rb rake gemspec
# ── Go / Rust / Swift ────────────────────────────────────────────────────
go rs swift
# ── Shell ────────────────────────────────────────────────────────────────
sh bash zsh fish ksh
# ── PHP / Perl / Lua ─────────────────────────────────────────────────────
php pl pm lua
# ── SQL ──────────────────────────────────────────────────────────────────
sql
# ── Other languages ──────────────────────────────────────────────────────
r hs ex exs erl elm dart jl cs fs fsx ml asm s
# ── Build / infra ────────────────────────────────────────────────────────
cmake gradle tf tfvars
# ── JSON family ──────────────────────────────────────────────────────────
json jsonl ndjson geojson
# ── YAML ─────────────────────────────────────────────────────────────────
yaml yml
# ── TOML ─────────────────────────────────────────────────────────────────
toml
# ── XML / Plist ──────────────────────────────────────────────────────────
xml plist
# ── INI / properties / env ───────────────────────────────────────────────
ini cfg conf config properties env
# ── Tabular data ─────────────────────────────────────────────────────────
csv tsv
# ── Logs ─────────────────────────────────────────────────────────────────
log ndlog
# ── Schema / query languages ─────────────────────────────────────────────
proto graphql gql
# ── Patch / diff ─────────────────────────────────────────────────────────
diff patch
# ── Docs / plain text ────────────────────────────────────────────────────
mdx txt rst
)
for ext in "${extensions[@]}"; do
echo " .$ext → $APP"
duti -s "$APP" ".$ext" all
done
echo "Done."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment