Two Claude accounts on one Mac, in 345 lines of bash
I wanted personal and work Claude accounts open side by side, with distinct Dock icons. Fixing an icon that disappeared after updates led to a small script tailored to my needs, instead of a 7,000-line tool.
7,051 → 541 → 525 → 345 lines of code
I pay for two Claude subscriptions: a personal one and a work one. I wanted both open at the same time in Claude Desktop and Claude Code, with separate sign-ins and distinct Desktop icons so I wouldn’t type a work prompt into the personal window. The awkward part was keeping those icons distinct after an update.
The result is claude-profiles: one bash script, no extra dependencies to install, 345 lines. Most of those lines deal with macOS behaviour I hadn’t expected. Keeping the script small also meant leaving out features I didn’t need.
The easy half: Claude Code
Claude Code lets you use a separate config folder for a profile’s settings, memory, and sign-in:
CLAUDE_CONFIG_DIR=~/.claude-work claude
You sign in separately for that folder. Account metadata lives in oauthAccount inside the folder’s .claude.json, which gives you a simple way to inspect the account recorded for a profile. I had been using this as a shell alias for months.
The hard half: Desktop
Claude Desktop is an Electron app, and --user-data-dir lets me give it a separate data folder. The problem was the Dock. The Dock tile belongs to the running app, so two instances of the same Claude.app look identical. I wanted to tell them apart at a glance.
For a while I used claude-multiprofile. It makes an APFS clone of Claude.app for each profile, gives the clone its own tinted icon, and adds a small launcher app for the Dock. The clone is effectively instant and initially takes almost no additional disk space. It worked well.
Then one day my black work icon was orange again.
The icon that kept coming back
The custom icon was stored as Finder metadata: a hidden file called Icon\r inside the bundle, plus a flag. When Claude updated, it replaced the bundle and removed the custom icon. The Dock showed the running app’s original icon, so work Claude looked exactly like personal Claude again.
Restoring the icon by hand from the terminal worked. Restoring it automatically didn’t. I set up a launchd agent to reapply the icon after every update, and it failed with:
touch: …/Claude work.app/.wtest: Operation not permitted I traced this to macOS App Management, which restricts changes to app bundles. My terminal had permission, but the background job didn’t. I could have granted permission to a helper app. First, though, I wanted to understand the behaviour, so I had Claude build a tiny probe applet and test which copies it could modify.
I observed that:
- The probe could freely change a copy of
Claude.appthat it had made itself. - It could not change the copy that claude-multiprofile had made.
- The copies carried different
com.apple.provenanceattributes depending on which process had created them.
In these tests, the app could modify the app bundles it had created. I built the design around that observation: only the Dock launcher creates or changes the profile’s copy of Claude. When clicked, it checks for a Claude update, rebuilds the copy if needed, restores the icon, and opens it. This worked without an App Management permission prompt in my tests. There’s no daemon watching for updates. The repair happens when I open the profile.
Should I trust the tool I already had?
Before rewriting anything, I asked Claude to audit the version of claude-multiprofile I had installed, 0.1.31. It reported no network code outside the upgrade command, no install scripts, and no Keychain access, and said the installed code matched the published package. I liked the tool.
But the version I was using contained 7,051 lines of Node, came from a single maintainer, and was installed through npm. If I wanted to inspect everything I was running, I’d have to review changes with each update. It also did far more than I needed.
I asked for a rewrite with a few requirements:
- Keep the code easy to read.
- Handle my two-account workflow better than the original.
- Support any number of profiles without adding special cases beyond two.
- Use bash and
osascript, with no runtime to install. - Make it public so others could read it too.
A profile is just a name
Supporting N profiles doesn’t need separate logic if every path comes from the profile’s name. There’s no registry or separate profile configuration file. If the profile folder exists, the profile exists. For work:
~/Applications/Claude work.app the Dock launcher
~/Library/Application Support/claude-profiles/work/ the copy of Claude, its icon, a log
~/Library/Application Support/Claude-work Desktop: sign-in, chats
~/.claude-work Claude Code: sign-in, settings, memory
~/.local/bin/claude-work the terminal command
One function, load_profile, defines this mapping. The rest of the script uses the variables it sets.
What macOS taught me
Each of these cost at least one broken run.
- Self-updates can reopen the wrong account. When the copy updated itself, the updater relaunched it without
--user-data-dir, so it came back signed in to my main account. Claude reads a “block auto-updates” policy from a folder next to its data folder (Claude-work-3p/configLibrary/). The script writes that policy only if theClaude-work-3pfolder doesn’t already exist, because it may contain the user’s third-party provider settings. - Focusing an app can relaunch it. If the app is quitting when you click the launcher,
open -acan start it again without the profile’s flags. The launcher therefore always passes the full--envand--args, even when it only intends to bring an existing window to the front. - Sign-in links can reach the wrong app. Browser sign-in returns through a
claude://link. Because the copy has the same bundle ID, macOS can hand that link to the main Claude. The workaround comes from claude-multiprofile’s own docs: choose “Continue with email” and type the code by hand, or quit the main Claude while signing in. - Dock launches don’t inherit your shell’s
PATH. The launcher calls the script by its full path, and the script sets its ownPATH. - The
osacompileapplets I created had no bundle identifier.PlistBuddy Setfailed because the entry didn’t exist. I neededAdd. Underset -e, that failure stopped the whole script. - A
PlistBuddydiagnostic ended up on stdout. Reading the version of a copy that didn’t exist yet returnedFile Doesn't Exist, Will Create. The script treated that message as a version, so it never built the copy. - Bash ignores
set -einside a function called asf || handle_error. The function that rebuilds the copy is called this way, so each step must check its own result. - My Mac’s filesystem ignores case. While cleaning up the old tool, Claude moved
Claude WORK.appto the Trash. That was the same path as the brand-newClaude work.app, so the new launcher went to the Trash too, and the pinned Dock icon followed it. When I asked whether the old icon daemon had done it, Claude replied, “No. I did it, the daemon had nothing to do with it.” Oneadd worklater, the launcher was back.
The features I said no to
Claude, and later the GPT reviewers, proposed several features I decided not to keep.
- A check at the start of every Claude Code session to verify the account. A friend suggested it, and I could see the appeal, but I didn’t want a separate session-start check. I kept the check in
claude-work: it runs once before launching Claude Code and stays quiet when everything is fine. - A “you’re on the wrong account” dialog. If you pin the running app’s tile instead of the launcher, the copy starts without its flags and opens your main account. Claude built a dialog intended to catch this. Then I opened the pinned tile, landed on the wrong account, and saw no warning. The check was in the launcher, which never ran. I asked, “So it’s effectively useless?” For that failure mode, it was. I removed it, along with a
WRONGstate in the status output. - A warning about the pinned tile. I pin that tile deliberately and accept the risk, so I reverted the warning.
- A README warning about case sensitivity, added after Claude had tripped over it. My response was, “No, it needs to be simpler.”
- fish support, automatic profile selection by folder, and a few other ideas for more specialised workflows.
Shrinking it with three models
Claude (Opus) wrote the code. For reviews, I deliberately used a different model family, GPT through Codex, hoping it would catch mistakes the author had missed. In one shrinking round, Fable, another Claude model, also built its own shorter variant to compare against.
The first review, gpt-6.1-sol at its highest reasoning effort, found real bugs. The worst was in the rebuild function: it could delete the old copy after a failed copy operation because set -e was ignored in that context. The review also found a shell-file edit that could truncate .zshrc if a marker went missing, and an account check that could read an email from an MCP server’s settings instead of the sign-in metadata. I had all of these fixed.
The later reviews focused on size. Each round, I asked, “What can go without losing anything?” I accepted cuts that preserved the behaviour I wanted to keep.
| Stage | Lines |
|---|---|
| claude-multiprofile | 7,051 (Node) |
| First version | 541 |
| Wrong-account dialog removed | 525 |
| Bugs from the first review fixed | 530 |
| Comments rewritten as names (63 comment lines → 19) | 495 |
| One check command, shell functions printed, file reordered | 397 |
| One icon function, smaller helpers | 327 |
| Commands as symlinks in ~/.local/bin | 345 |
A few changes were particularly useful:
- Comments became names. I borrowed a rule from another codebase: treat a comment as a reason to look for a better name.
# set_icon <image> <app>becamelocal image=$1 app=$2. The remaining comments explain why the code does something, including the macOS behaviour described above. listandcheckmerged. One command prints the profiles and their accounts, followed by any problems and how to fix them. When nothing is broken, the output needs no separate “all good” message.- The script stopped editing
.zshrc. The first version wrote a marked block into it. The next printed functions foreval. Nowaddputs symlinks in~/.local/bin, the directory used by Claude Code’s native installer. The script selects the profile from the name it was invoked with. Provided that directory is already onPATH, the command works in existing terminals without editing shell files or running anything at shell startup.
I didn’t accept every cut. One reviewer proposed removing the coloured icons (−37 lines) and reducing the settings choice to one mode (−18). Both would have been acceptable for my own use. But a profile without a distinct icon looks exactly like the main Claude in the Dock. That was the problem I wanted to solve, so I kept the colours.
Try it
You need macOS, Claude Desktop in /Applications, and Claude Code.
git clone https://github.com/ishaba/claude-profiles ~/.local/share/claude-profiles
~/.local/share/claude-profiles/claude-profiles add work
Drag ~/Applications/Claude work.app to the Dock, open it, and sign in with “Continue with email”. In a terminal, run claude-work and sign in to the same account there. claude-profiles check reports problems it finds.
It’s unofficial: it relies on an Electron flag and on how Claude Desktop reads its data and update settings, so a future Claude release might break it. If that happens, there’s one file to inspect and fix.
Credit where it’s due
The core ideas come from claude-multiprofile (MIT): copying the app for a separate icon, blocking self-updates through the -3p folder, carrying MCP servers over, and using email codes to sign in. My contribution is narrower. I investigated why the background icon repair failed, then used what I observed to make the repair run from the launcher, without a persistent background process.
If claude-profiles saves you an afternoon, a star on GitHub can help other people with two Claude accounts find it. If you encounter a macOS quirk I missed, open an issue. I’d like to add it to the list.