Windows / PC muscle memory on a Mac, with a SwissβGerman ISO keyboard. One bootstrap for the apps, the keyboard layout, the Karabiner remaps, the IDE keymaps, the few macOS tweaks that go with them, and the shell dotfiles.
π¨π layout Β βΒ β¨οΈ Karabiner Β βΒ π§ JetBrains keymap Β βΒ π» VS Code family Β βΒ π dotfiles
Clone it into whatever folder you keep your repos in, e.g. ~/code:
cd ~/code # any folder works
git clone git@github.com:JtheGunner/macos-base-config.git
cd macos-base-config
./bootstrap.sh --dry-run # see what would happen
./bootstrap.sh # every step
./bootstrap.sh keymaps # only some steps
./bootstrap.sh --skip dotfilesNote
Where you clone it does not matter. bootstrap.sh puts the sibling repos
next to this one, in the same parent folder, and every script finds them
there. No path is hard-coded.
<your folder>/
βββ macos-base-config/ β you clone this
βββ karabiner-windows-keyboard-mapping-macos/ β bootstrap.sh clones the rest
βββ swiss-windows-keyboard-layout-macos/
βββ intelli-key-port/
βββ dotfiles/ β or DOTFILES_DIR, see Configuration
A sibling that is already there is updated with git pull --ff-only
instead of cloned again.
bootstrap.sh is best-effort and re-runnable: a failing step is reported in
the summary at the end (exit code 1), it never aborts the rest.
./bootstrap.sh [step ...]
β
ββ repos clone / pull each sibling repo (repos.txt) into the parent folder
ββ brew Homebrew (installed if missing) + the selected packages (default: Karabiner, AltTab, Sidebar, font)
ββ extras selected packages from outside Homebrew: install scripts, npm, pipx, uv, go; the nas-mount app
ββ karabiner Karabiner config β ~/.config/karabiner
ββ keyboard Custom Swiss German layout β ~/Library/Keyboard Layouts, enabled + selected
ββ macos macos-defaults.py: system hotkeys, Finder shortcut, font smoothing (+ Gatekeeper, opt-in)
ββ jetbrains JetBrains keymap β IDE config, set active (skipped until PhpStorm has a config)
ββ vscode same keymap β VS Code / Antigravity (same condition)
ββ editor font settings β settings.json of every VS Code-family editor
ββ apps app settings from SETTINGS_DIR, per apps/registry.txt (no licenses)
ββ dotfiles the dotfiles repo's own bootstrap.sh: shell, prompt, git, tmux, Ghostty
ββ manual the manual steps, one at a time
ββ summary ok / skipped / failed per step
Important
On a fresh Mac the brew step installs Homebrew first, which asks once for
your password. Karabiner then still needs its permissions (Driver Extension,
Input Monitoring, Accessibility), see Manual steps.
Name steps to run only those; they always run in the order below. With no step named, every step runs.
| Step | Runs | |
|---|---|---|
| π₯ | repos |
clone missing / pull existing sibling repos |
| πΊ | brew |
Homebrew installer if missing, brew bundle of the selected packages |
| π§© | extras |
install scripts, npm -g, pipx, uv tool, go install for the selected packages |
| β¨οΈ | karabiner |
karabiner-windows-keyboard-mapping-macos/apply.sh |
| π¨π | keyboard |
layout copy + enable-input-source.swift |
| π οΈ | macos |
macos-defaults.py, Gatekeeper if opted in |
| π§ | jetbrains |
ide-keymaps/apply.sh; skipped while PhpStorm / IntelliJ runs |
| π» | vscode |
ide-keymaps/port-vscode.sh |
| π€ | editor |
editor-settings/apply.py |
| ποΈ | apps |
apps/app_settings.py apply |
| π | dotfiles |
dotfiles/bootstrap.sh |
| β | manual |
walk through the manual steps |
keymaps is an alias for jetbrains vscode. A step that needs a sibling repo
clones it itself, so ./bootstrap.sh dotfiles works on its own.
| Option | Effect |
|---|---|
--skip <step> |
skip a step or alias; repeatable |
--dry-run |
show what would happen; sub-tools get --dry-run, no git, no dotfiles run |
--no-pull |
don't update siblings that are already cloned |
--yes |
don't wait for input: the manual step is a plain list |
--config <path> |
use this config file |
--list |
list the steps |
--list-packages |
list the package catalog: what is selected, what is installed |
--save-settings [appβ¦] |
save the app settings into SETTINGS_DIR, see App settings |
--init-config <git-url> |
clone your private config repo into the config dir, see Private config repo |
-h, --help |
usage |
While it runs:
- It asks for your password once, when a step first needs it (Homebrew,
the app installers, Rosetta, Gatekeeper) - and not at all when there is
nothing to install. Homebrew forgets sudo's sign-in on almost every
brewcommand, so the password is handed to Homebrew and sudo throughSUDO_ASKPASS: a helper reads it from a named pipe in a private temp folder. It is kept only in the running bootstrap's memory - never in a file, the environment or a command line - and the pipe is removed when the run ends. While the run lasts, any program of your user could read it. - Its own lines are coloured: step headers,
+ commands, warnings, and the summary by status. SetNO_COLOR=1to turn that off; piped output has none. - It opens no windows on its own until the
manualstep. App installers may still open theirs (TeamViewer, Google Drive, β¦) or ask for permissions: close them, themanualstep walks you through what matters.
Per-machine settings live in ~/.config/macos-base-config/config.sh, a plain
bash file that is not part of the repo. Start from the template:
mkdir -p ~/.config/macos-base-config
cp config.example.sh ~/.config/macos-base-config/config.shKeep that folder as a private git repo, and a new Mac gets your config and app settings in one step:
~/.config/macos-base-config/ β your private repo, e.g. macos-private-config
ββ config.sh PACKAGES, NAS_MOUNT_SHARES, β¦
ββ settings/ app settings (./bootstrap.sh --save-settings)
./bootstrap.sh --init-config git@github.com:<you>/macos-private-config.git # new Mac
./bootstrap.sh # then everything--init-configclones only into a missing or empty folder. A folder with other files, or a checkout of another repo, is left alone (exit 2).- The
reposstep pulls it on every run (--no-pullskips). A checkout with local changes is not pulled, only warned about. A pulledconfig.shtakes effect on the next run. - Cloning a private repo needs your GitHub access (SSH key or
gh auth login) set up first, like the sibling repos. - License keys and passwords never go into it:
--save-settingsrefuses to write them.
| Key | Default | Effect |
|---|---|---|
BOOTSTRAP_STEPS |
all steps | steps to run when none are named on the command line |
BOOTSTRAP_SKIP |
β | steps never to run on this machine |
PACKAGES |
@base |
packages to install from the catalog: ids, @category, @all; a leading - removes; "" = none |
BREW_BUNDLE_EXTRA |
β | extra Brewfile for apps outside the catalog, installed after the selected packages |
MACOS_DISABLE_GATEKEEPER |
0 |
1 = allow apps from anywhere (sudo spctl --master-disable); macOS asks you to confirm in Privacy & Security |
SETTINGS_DIR |
settings/ next to config.sh |
private app settings (alt-tab.plist, sidebar.sidebarbackup, β¦), see App settings |
NAS_MOUNT_SHARES |
β | smb:// / afp:// / nfs:// shares the nas-mount app mounts, see NAS shares |
DOTFILES_DIR |
<parent>/dotfiles |
dotfiles checkout to use, e.g. an existing ~/Git/dotfiles |
DOTFILES_URL |
URL in repos.txt |
clone URL, e.g. a fork |
DOTFILES_ASSUME_YES |
0 |
1 = repoint stow links from another checkout without asking |
DOTFILES_TERMINALS |
β | extra terminal stow packages for the dotfiles bootstrap |
DOTFILES_OMNISHELL_CONFIG |
β | own omnishell config.toml, applied after the dotfiles bootstrap |
DOTFILES_LOCAL_RC |
β | shell lines for ~/.zshrc.local + ~/.bashrc.local, see Machine-local shell aliases |
Steps named on the command line replace BOOTSTRAP_STEPS; --skip adds to
BOOTSTRAP_SKIP. An invalid config (syntax error, unknown step, bad value)
stops the bootstrap before any step runs.
Tip
Already have the dotfiles checked out somewhere else? Set DOTFILES_DIR to
that checkout. Otherwise, the dotfiles bootstrap asks whether to repoint every
stow link to the new <parent>/dotfiles clone. If you decline, or there is no
terminal to ask, the dotfiles step fails and names both ways out:
DOTFILES_ASSUME_YES=1 (repoint without asking) or DOTFILES_DIR.
| Piece | Repo / file | Apply | |
|---|---|---|---|
| π¨π | AltGr characters (@ # | \ ~ [] {} β¬): custom keyboard layout |
swiss-windows-keyboard-layout-macos |
the keyboard step installs and enables it |
| β¨οΈ | Windows key behaviour (Ctrl+C/V/Z, word jump, Alt+F4, β¦): Karabiner |
karabiner-windows-keyboard-mapping-macos |
./setup.sh (fresh Mac) or ./apply.sh: bootstrap runs apply.sh |
| π οΈ | macOS-level shortcuts Karabiner can't do | macos-defaults.py (this repo) |
python3 macos-defaults.py: bootstrap runs it |
| π§ | JetBrains keymap jeffry-default-macos-win Proper Redo |
ide-keymaps/ (this repo): README |
ide-keymaps/apply.sh (quit the IDE first): bootstrap runs it; also turns the terminal's Use Option as Meta key off |
| π» | VS Code / Antigravity keybindings, generated from the JetBrains keymap | intelli-key-port + layers from ide-keymaps/ |
ide-keymaps/port-vscode.sh: bootstrap runs it |
| π | Shell, prompt, git, tmux, Ghostty | dotfiles |
its own bootstrap.sh: the dotfiles step runs it |
Warning
Port the VS Code keybindings only through ide-keymaps/port-vscode.sh. It
adds the windows-keymap and karabiner-winkeys layers and the personal
layer. A bare ./port.py in intelli-key-port installs a neutral port over
your current bindings. It backs them up first.
AltGr is the right Option key. macOS apps cannot tell left from right Option,
so any IDE shortcut on Option+<digit> swallows the character AltGr+digit
should type. The pieces are set up to keep both working:
| Piece | What it does | |
|---|---|---|
| π¨π | keyboard layout | AltGr+1 / 2 / 3 / 7 type | @ # | |
| β¨οΈ | Karabiner rule 45 | in IDEs, the physical Alt key + digit becomes Ctrl+Shift+Alt+Cmd + digit (on the MX Keys in Mac mode, Alt arrives as left_command) |
| π§ | JetBrains keymap | tool windows (Project, Find, Structure, β¦) sit on Ctrl+Shift+Alt+Cmd+<digit>, so no Option+<digit> shortcut is left |
| π» | port-vscode.sh |
carries those bindings over to VS Code / Antigravity |
Result: Alt+digit opens a tool window, AltGr+digit types the character.
In the PhpStorm terminal, Use Option as Meta key must be off, or AltGr
characters turn into escape sequences. ide-keymaps/apply.sh turns it off, in the
IDE config and in Settings Sync's local copy, so a sync pull cannot switch it back on.
Karabiner is an event remapper with no awareness of app menus or text-field
focus, so a few things must be done at the macOS defaults level.
macos-defaults.py does them idempotently and writes a restore-<timestamp>.sh
per run:
- Mission Control
Ctrl+β/β"Move a space" (symbolic hotkeys 79β82) is disabled. Otherwise, macOS takesCtrl+Arrowbefore any app sees it, and word navigation never works in editors. - "Turn VoiceOver on or off" (symbolic hotkey 59,
Cmd+F5) is disabled. Karabiner sendsCtrl+F5asCmd+F5, which would start VoiceOver. - Finder: forward-delete key β "Move to Bin" via an App Shortcut
(
NSUserKeyEquivalents). It is menu-aware, so it still deletes forward inside a rename or search field. A Karabiner rule could not tell the difference. AppleFontSmoothing = 1: thinner bold system font.
python3 macos-defaults.py --show # current state
python3 macos-defaults.py --dry-run
python3 macos-defaults.py # apply; log out / in for the hotkeys + fontmacOS doesn't let a script do these. In a terminal, the manual step walks
you through them one at a time: Enter opens the app or System Settings
pane, Enter again when done, s skips. With --yes, in a dry run or
with piped output it prints them as a list.
It remembers what is done, so a re-run only shows what is left:
- a step you confirmed with Enter is kept in
~/.local/state/macos-base-config/manual-done(delete the file to go through all of them again). A step whose text changes - say, a new app whose license you still have to enter - comes back. - Gatekeeper, the input source and the Karabiner driver are checked on this Mac directly.
- Apps to install by hand or from the App Store count as done only once the
app is in
/Applications- confirming them doesn't hide them.
| Step | How | |
|---|---|---|
| π | Karabiner permissions | Driver Extension, Input Monitoring, Accessibility. karabiner-windows-keyboard-mapping-macos/setup.sh opens the panes and lists the steps |
| π¨π | Input source | check Custom Swiss German under System Settings β Keyboard β Input Sources, then log out and in. The keyboard step enables it when it can (with swift; without a working one it adds the layout for your next login) |
| π‘οΈ | Gatekeeper | only with MACOS_DISABLE_GATEKEEPER=1: confirm "Allow applications from: Anywhere" under Privacy & Security (the macos step requests it, the manual step opens the pane; since macOS 15 the change only takes effect once confirmed there) |
| π | Sidebar settings | only when Sidebar is installed and its backup is in SETTINGS_DIR: Sidebar β Settings β Expert β Backups β Restore the backup the apps step added (Sidebar has no way to import from a script) |
| π | Licenses | the installed ones of AltTab (Pro), Sidebar and Shottr: enter the keys from your password manager in each app; Tabby asks for its vault passphrase |
The public dotfiles repo keeps
only generic aliases. Anything tied to this machine's setup goes in
~/.zshrc.local / ~/.bashrc.local, which the dotfiles source last and never
version-control. Set them through DOTFILES_LOCAL_RC in your config.sh:
DOTFILES_LOCAL_RC='
# Kubernetes dashboard: print a login token for the admin-user service account
command -v kubectl >/dev/null 2>&1 &&
alias kdash-token="kubectl -n kubernetes-dashboard create token admin-user"
'The dotfiles step writes these lines into both files as one managed block:
# >>> macos-base-config >>>
# managed by macos-base-config (DOTFILES_LOCAL_RC) - edit its config.sh, not this block
...
# <<< macos-base-config <<<
- Every run replaces the block; whatever else is in those files stays as it is.
- An empty
DOTFILES_LOCAL_RCremoves the block. - A file that doesn't exist yet is created readable only by you (
600), since it may hold tokens. - The lines must be valid for both bash and zsh. A syntax error stops the bootstrap before any step runs.
Run ./bootstrap.sh dotfiles, then open a new shell (exec $SHELL).
/bin/bash tests/bootstrap-test.shRuns the argument parsing, config and step tests under macOS's bash 3.2. The end-to-end cases run in a throwaway sandbox with stub tools, so nothing on the machine changes.
Everything the bootstrap can install is listed in the catalog,
packages/catalog.txt: one package per line with its
source (Homebrew formula or cask, App Store, β¦) and a description. Nothing
is mandatory: pick what this Mac gets with PACKAGES in the
config.
./bootstrap.sh --list-packages # the catalog: [x] selected, installed / missing
./bootstrap.sh brew extras # install the selectionWhere a package comes from decides which step installs it:
| Source | Installed by | Step |
|---|---|---|
formula |
brew bundle (brew "β¦") |
brew |
cask |
brew bundle (cask "β¦") |
brew |
mas |
brew bundle (mas "β¦"), App Store |
brew |
script |
the vendor's install script (curl β¦ | bash) |
extras |
npm |
npm install -g |
extras |
pipx |
pipx install |
extras |
uv |
uv tool install |
extras |
go |
go install β¦@latest |
extras |
applet |
built from a template in this repo (nas-mount) |
extras |
manual |
you: the manual step prints the download link |
manual |
PACKAGES |
Installs |
|---|---|
| (key not set) | @base: Karabiner-Elements, AltTab, Sidebar, JetBrains Mono |
"" |
nothing |
"alt-tab font-jetbrains-mono" |
just these packages |
"@all -sidebar" |
everything except one package (-@category removes a whole category) |
The @base packages:
| Package | For | |
|---|---|---|
| β¨οΈ | Karabiner-Elements | Windows key behaviour (karabiner step) |
| π | AltTab | Windows-style Alt+Tab window switching |
| π | Sidebar | Windows-style taskbar, Dock replacement |
| π€ | JetBrains Mono | editor font, see Fonts |
The whole catalog at a glance. Homebrew wherever it has the package; the
source column in packages/catalog.txt says where each one comes from.
| Category | Packages | |
|---|---|---|
| π§± | base |
karabiner-elements, alt-tab, sidebar, font-jetbrains-mono |
| π | browser |
firefox, google-chrome |
| π» | dev |
visual-studio-code, antigravity, antigravity-ide, jetbrains-toolbox, tabby, docker-desktop, dbeaver-community, postman, wireshark-app, filezilla (download link) |
| π€ | ai |
claude, claude-code, lm-studio, mlx-dspark |
| π§° | productivity |
bitwarden, google-drive, rectangle, shottr, trex, maccy, mouseboost-pro (App Store), pearcleaner, swiftdefaultappsprefpane |
| π¬ | communication |
telegram, whatsapp |
| π°οΈ | remote |
tailscale-app, teamviewer, windows-app, nas-mount |
| π΅ | media |
spotify, vlc, steam |
| π | cli-shell |
coreutils, htop, tlrc, screenfetch, iproute2mac |
| π οΈ | cli-dev |
gh, git-filter-repo, go, gopls, nvm, pipx, uv, python@3.12, python@3.14, php@8.3, php@8.4, composer, qodana, sass |
| ποΈ | cli-ops |
mariadb, mysql-client, helm, sshpass |
| π§ | cli-ai |
summarize, openai-whisper, hf, mlx-lm, antigravity-cli, litellm, mlx-vlm, mlx-dspark-cli (pipx), nano-pdf (uv), continue-cli, openclaw, clawhub (npm) |
| π | cli-docs |
ghostscript, poppler, tesseract, tesseract-lang |
| π | cli-macos |
codexbar, remindctl, memo, spogo, dutix |
Shell tools (bat, eza, fd, fzf, ripgrep, starship, tmux, zoxide, β¦) are not in
the catalog: the dotfiles step
installs them.
- Installed apps are never upgraded by the bootstrap (
--no-upgrade); they update themselves. - An app already in
/Applicationsis left alone, even when Homebrew didn't install it. - App Store packages need you signed in to the App Store. Paid apps must
already belong to your Apple ID. In a terminal, the
brewstep asks first (Enter installs, s skips), then runsmas installitself; an App Store app that didn't install is a warning, not a failed step, and themanualstep links it in the App Store. (Virtual machines often can't sign in to the App Store at all.) - Apps outside the catalog go in your own Brewfile: set
BREW_BUNDLE_EXTRA. - Packages from third-party taps (
<user>/<tap>/<name>) are trusted one by one withbrew trustbefore the bundle: Homebrew 7 skips packages from taps nobody trusted. A tap whose repo isn'tgithub.com/<user>/homebrew-<tap>needs its URL inpackages/taps.txt. - A versioned formula such as
php@8.4is keg-only: Homebrew doesn't link its commands (php) into thePATH, andbrew bundleeven unlinks one you linked by hand. The catalog marks the default versionformula+linkin its source column, and the generated Brewfile then carrieslink: :overwrite. Mark one version per command; the other versions stay keg-only. - A failed
brew bundle(often a download reset on a busy network) is tried once more; after that, the step names what is still missing. - Steam is built for Intel: when it is selected and Rosetta 2 is missing, the
brewstep installs Rosetta first. pipx,uvandgoare installed by thebrewstep when a selected package needs them.npmpackages need Node:nvm install --ltsfirst.- A package whose command is already on your
PATHis left alone, however it was installed.~/.local/binand~/go/bin(where pipx,uv tool,go installand most install scripts put commands) count too; put them on your shell'sPATHto use what lands there. - Install scripts are fetched over https only.
nas-mount is a small app that mounts your NAS shares in Finder; open it or
add it to your login items. Select it with nas-mount in PACKAGES and list
the shares in the config:
PACKAGES="@base nas-mount"
NAS_MOUNT_SHARES="smb://nas.local/data smb://nas.local/media"- The
extrasstep builds/Applications/nas-mount.appfrompackages/nas-mount.applescript. Each share has its owntry, so one that is offline doesn't stop the rest. - No credentials in the config or the app: Finder asks once and keeps them in the Keychain.
- Changed shares rebuild the app on the next run; the old one goes to the Trash. Unchanged shares leave it alone.
- Shares are separated by spaces or newlines; write a space inside a path as
%20.
The apps step brings your app settings onto the Mac. Which apps, and where
each one keeps its settings, is listed in apps/registry.txt.
The settings themselves are private and never part of this public repo:
they live in your settings directory, SETTINGS_DIR, by default
settings/ next to your config file
(~/.config/macos-base-config/settings/, or the config folder itself if it
has no settings/). No license is ever exported: enter those from your
password manager once per Mac.
| App | File in SETTINGS_DIR |
On ./bootstrap.sh apps |
|
|---|---|---|---|
| π | AltTab | alt-tab.plist: its preferences, minus window frames, update and telemetry state |
merged into its preferences; a running AltTab restarts. Unchanged settings leave it alone |
| π | Maccy | maccy.plist: its preferences, minus menu-bar, window and update state |
merged into its preferences; a running Maccy restarts |
| πΈ | Shottr | shottr.plist: its preferences, without its license (kc-license, kc-vault, token), device ids, update checks and telemetry |
merged into its preferences; a running Shottr restarts |
| πͺ | Rectangle | rectangle.plist: its preferences and shortcuts, minus dialog and update state |
merged into its preferences; a running Rectangle restarts |
| π₯οΈ | Tabby | tabby.yaml: its config.yaml as is, encrypted with your Tabby vault |
copied in (the old one kept as .bak-<time>), again only when the saved file changed - Tabby rewrites its own on start; Tabby asks for the vault passphrase from your password manager |
| π | Sidebar | sidebar.sidebarbackup: a Sidebar backup without license, usage data, statistics, calendars or window state |
added to Sidebar's backup list; restore it there (Settings β Expert β Backups) |
Everything is optional: an app that isn't installed, or has no file in the settings directory, is skipped. Without a settings directory the step is skipped.
Adding an app is one line in the registry, in one of three kinds:
| Kind | For | Export | Apply |
|---|---|---|---|
defaults |
a preferences domain (most Mac apps) | the domain without window / update / telemetry state and without license or token keys | merged into the domain; a running app is restarted |
file |
an app with a settings file | the file as it is | copied in; the old file is kept as .bak-<time> |
sidebar |
Sidebar's backup format | the newest backup, stripped of license and usage data | added to Sidebar's backup list |
To save the settings of this Mac (for Sidebar, first create a backup in Sidebar β Settings β Expert β Backups β Create backup):
./bootstrap.sh --save-settings # every installed app in the registry
./bootstrap.sh --save-settings alt-tab # only these apps (registry ids)- The files go to
SETTINGS_DIR, readable only by you (600). - A secret guard checks every file before it is written: a key that looks like a license, token, serial, password or secret stops that app's export (the file is not written, the key is named, exit 1). Tabby's vault-encrypted config passes.
- When
SETTINGS_DIRis in a git repo (your private config repo), the command showsgit statusandgit diff --stat. It never commits: review, then commit and push yourself.
Tip
Keep the config and settings in iCloud Drive to have them on a new Mac
right after signing in, before anything is installed:
./bootstrap.sh --config "$HOME/Library/Mobile Documents/com~apple~CloudDocs/macos-base-config/config.sh".
The settings directory follows the config file.
The brew step installs JetBrains Mono (package font-jetbrains-mono, in
@base). The editor step sets the keys from
editor-settings/vscode.jsonc (font family,
size, weight) in the User/settings.json of every VS Code-family editor it
finds: VS Code, VS Code Insiders, VSCodium, Cursor, Windsurf, Antigravity,
Antigravity IDE. An editor that is installed but was never started gets a new
settings.json.
./bootstrap.sh editor --dry-run # diff per settings.json
./bootstrap.sh editor- Only those keys change; comments and every other setting stay as they are.
- A changed file is backed up first (
settings.json.bak-<timestamp>); a file that already has the values is not touched. - To change the font, edit
editor-settings/vscode.jsoncand run the step again. The editors pick up the new settings without a restart.
Issues and pull requests are welcome. Run the tests before opening a PR; for larger changes, open an issue first.
MIT β see LICENSE. Β© 2026 Jeffry WΓΌrmli.
./bootstrap.sh away.