Skip to content

Instantly share code, notes, and snippets.

@korchasa
Created July 27, 2026 16:30
Show Gist options
  • Select an option

  • Save korchasa/14f7fe7ca6c050dea9a051d5acdf8a44 to your computer and use it in GitHub Desktop.

Select an option

Save korchasa/14f7fe7ca6c050dea9a051d5acdf8a44 to your computer and use it in GitHub Desktop.
claude-selector-prompt.md

Prompt: two copies of Claude Desktop and a claude:// link router


Who you are and what to build

You are working on macOS 13 or newer. There are two things to do, in this order:

  1. Split Claude Desktop into two copies — personal and work — so each runs against its own Claude Code profile, with its own account, its own data and its own icon.
  2. Write a resident menu-bar app that intercepts every claude:// link and asks which copy should open it, and that also shows how much is left of each copy's plan limits.

The first part creates the problem the second one solves: both copies claim the claude: scheme, the system picks a winner on its own, and the same kind of link lands in a different app from one day to the next.

Before writing any code, ask the questions in "What to ask before starting".

What has to be in place first

Check each of these before you start, and say which ones are missing rather than working around them:

  • Claude Desktop is installed at /Applications/Claude.app, signed by Anthropic.
  • The claude CLI is on PATH — the profile checks below go through it.
  • Two Anthropic accounts, each on a plan with limits. The second copy is pointless otherwise, and Part B has nothing to draw: the usage endpoint reports plan limits only for a subscription login. With ANTHROPIC_API_KEY, Bedrock or Vertex there are no limits to show.
  • Xcode with its command-line tools, on a Swift 6.0 toolchain or newer. Package.swift declares swift-tools-version:5.9 — that is the language mode, not the toolchain. The build gate runs swift format, which became a toolchain subcommand in Swift 6.0 (Xcode 16); on Xcode 15 that step fails with an unknown subcommand. Check with swift format --version before writing the script.
  • codesign and PlistBuddy, both of which come with the command-line tools.

Part A. Two copies of Claude Desktop

What you should end up with

Original Work copy
Bundle /Applications/Claude.app /Applications/Claude Work.app
Identifier com.anthropic.claudefordesktop dev.<your-prefix>.claude-work
Claude Code profile ~/.claude ~/.claude-work
Electron data ~/Library/Application Support/Claude ~/Library/Application Support/Claude-work
URL schemes claude: claude: and claude-work:
Signature Anthropic ad-hoc

The second profile does not exist yet

~/.claude-work is not created by anything above — a config directory appears when a Claude Code login writes into it. So before the copy is worth building, make the profile and sign the second account into it:

CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude

Then run /login inside that session and sign in as the second account. The built copy of Claude Desktop will pick the same directory up through its wrapper, so this login is what both the copy and Part B's limits read.

To confirm the profiles really differ:

claude auth status; CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude auth status

The two lines must show different email addresses and different subscription types. Two identical lines mean the second login did not land — building the copy on top of that only hides the problem.

Why a copy rather than a wrapper

Only a real bundle gets its own Dock icon, its own entry in Cmd+Tab and its own URL scheme. A wrapper script around the original inherits the original's identity in all three places.

The rebuild script

Write a script (Deno + TypeScript, or bash — your call) that performs the steps below. It has to be repeatable: the auto-updater only ever touches the original, so the copy goes stale after every Claude release and must be rebuilt.

  1. Preflight. If the copy is running, stop with a clear message. A running bundle cannot be signed.

  2. Copy. ditto the original to the target path. Then remove Contents/embedded.provisionprofile and Contents/_CodeSignature.

    • Trap. The provisioning profile is bound to Anthropic's bundle identifier. Leave it in an ad-hoc copy and macOS rejects the signature at launch.
  3. The icon. Put your own appIcon.icns into Contents/Resources. Nothing in the steps below produces it: the stock bundle ships one icon file (electron.icns) and its real icon comes out of Assets.car, so a copy that is only re-pointed at a name has no icon of its own to point at. Make the file first — take the original's artwork and mark it, or draw one — and give it a name that is not electron, so a rebuild copying the original over it cannot overwrite it. Two copies of Claude with the same icon in the Dock and in Cmd+Tab is the failure this whole part exists to avoid.

  4. Identity in Info.plist, via /usr/libexec/PlistBuddy:

    • CFBundleDisplayName → "Claude Work", CFBundleIdentifier → yours, CFBundleIconFileappIcon (the file from the previous step, without its extension), delete CFBundleIconName.
    • Trap. CFBundleName must not change. Electron derives the paths of its helper apps from it, and after a rename the copy dies at startup with "Unable to find helper app". What a person actually sees is CFBundleDisplayName — change that one.
    • Trap. CFBundleIconName points at an icon inside Assets.car and wins over CFBundleIconFile. Leave it in place and the copy keeps the stock icon.
  5. URL schemes. Append your own claude-work: as a further entry in CFBundleURLTypes — read what is already there rather than assuming a count, since Anthropic ships more than one — and keep the stock claude:.

    • Trap. Sign-in callbacks come back on claude:. Remove it and the copy cannot authenticate.
  6. Launcher wrapper. Rename Contents/MacOS/Claude to Claude.real and put a zsh script in its place:

    #!/bin/zsh
    export CLAUDE_CONFIG_DIR="$HOME/.claude-work"
    exec "$(dirname "$0")/Claude.real" \
      --user-data-dir="$HOME/Library/Application Support/Claude-work" \
      --use-mock-keychain "$@"
    • The wrapper catches every way of launching: Dock, Spotlight, Finder, open.
    • --use-mock-keychain is required: the "Claude Safe Storage" keychain item belongs to the Anthropic-signed original, and to the keychain an ad-hoc copy is a different app. Without the flag macOS asks for the login keychain password on every launch. The trade-off is that local data is encrypted with a key kept beside it rather than one held in the keychain.
    • This same wrapper is what the app in Part B reads: the CLAUDE_CONFIG_DIR= line in the bundle's executable is how it learns which profile a copy runs against. Do not hide it and do not compile it away.
  7. Sign inside out. Collect the nested code (.app, .framework, .xpc under Contents/Frameworks and Contents/Helpers), order it so nested code is always signed before its container, and sign everything ad-hoc (codesign --force --sign - --timestamp=none).

    • Trap. codesign --deep is deprecated and mis-orders Electron's helpers. Build the list yourself.
    • Trap. Each component's entitlements must have the Team-ID-bound ones stripped: com.apple.application-identifier, com.apple.developer.team-identifier, keychain-access-groups. An ad-hoc signature cannot honour them, and leaving them in makes the signature invalid. Everything else — com.apple.security.cs.allow-jit above all, which Electron's helpers need — has to survive, so the entitlements are edited rather than dropped. Per component: read them with codesign -d --entitlements :- --xml <component>, delete the three keys from that plist, write it to a temporary file, and pass it back through codesign --entitlements <file>. A component that reports none gets signed without the flag.
    • The outer bundle is the exception worth knowing: signing it with no entitlements at all works, because everything it needs lives in the helpers. Verified on a working copy — the top-level bundle carries none and the app runs. Only the nested code genuinely needs the edited set.
    • Finish with codesign --verify --strict <bundle>.
  8. Register: lsregister -f <bundle> (the tool lives at /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister).

What is broken now, and why Part B exists

Ask the system:

/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -dump | grep -B5 -A5 'claude:'

Both copies claim claude:, the system picks the handler on its own, and the pick changes after either bundle is re-registered. That is the problem.

A rule saying "always open in copy X" does not solve it: a claude:// link carries no sign of which copy it belongs to — a sign-in callback for the second copy looks exactly like one for the first. So the choice has to be asked every time. Remembering it is impossible in principle, not merely unimplemented.


Part B. The selector app

What it does

A resident agent with no window and no Dock icon, living as a menu-bar item.

  • Declares the claude scheme and, once made the default handler, receives every such link as an Apple Event (kAEGetURL).
  • Finds the copies by asking LaunchServices, not by scanning /Applications: NSWorkspace.urlsForApplications(toOpen:) on a probe URL like claude://example.com returns everything that claims the scheme. Drop your own bundle from that list — the selector claims the scheme too, and a row offering to open the link in itself is a loop waiting to happen — and drop duplicate bundle identifiers. Re-run it for every incoming link; it is cheap, and it is what makes a freshly rebuilt copy of Claude appear without a restart.
  • Shows a picker beside the cursor: what the link asks for on top — its host or first path segment as a headline, the whole URL under it, so nothing about the incoming request stays hidden — then the copies of Claude, each with its icon, name, bundle identifier and a number key. When more links are waiting, the picker says how many.
  • Opens the link in the chosen copy by application URL, not through the scheme.
  • Draws what is left of each copy's plan limits under it in the menu, as bars: the rolling five-hour window and the weekly one.
  • Carries the worst forecast across all copies in the menu-bar icon, so "a limit is about to run out" is visible without opening anything.
  • Keeps a Quit item in the menu. An agent with LSUIElement has no Dock icon to quit from, so without one the only way out is pkill.
  • Offers launch at login through SMAppService.mainApp. It registers the path of the bundle it is running from, so a build that later moves breaks the login item — register it from wherever the app is meant to live.

Technical constraints

  • SwiftUI + AppKit, macOS 13+, no package dependencies.
  • swift-tools-version:5.9 in Package.swift; built with a Swift 6.0 toolchain or newer, which is what swift format in the build gate needs.
  • Swift Package Manager: an executable target plus a test target.
  • The .app is assembled by a script — SwiftPM does not build bundles.
  • LSUIElement in Info.plist and NSApp.setActivationPolicy(.accessory) at launch: the plist keeps the agent out of the Dock, and the call keeps it that way when the app is run straight out of the build directory.

Structure

Keep all arithmetic in pure functions and let the views only draw. Tests cover the arithmetic; views are checked by eye against a rendered image (see "How to look at the UI").

File Responsibility
App.swift MenuBarExtra in window style, the panel's content, an AppDelegate holding the Apple Event handler and the picker window
InstanceStore.swift State: the copies, the link queue, the limits, the scheme's handler status, launch at login
Models.swift One installed copy: name, bundle identifier, bundle URL
PickerView.swift The picker window
PickerPlacement.swift Pure placement of the window relative to the cursor
PickerKeys.swift Key handling: digits, arrows, esc
LinkLabel.swift Pure headline and subtitle for an incoming link
InstanceOrder.swift Stable order of the copies
UsageProfile.swift Parsing CLAUDE_CONFIG_DIR out of the wrapper, and the keychain service name
UsageFetcher.swift Reading the token and calling the endpoint
Usage.swift The usage snapshot, response decoding, wording
UsagePace.swift The pace arithmetic
UsageGauge.swift The bar with its tick, and the block of two windows
MenuBarIcon.swift Drawing the menu-bar image with its badge
DefaultHandlerIntent.swift The pure "should the scheme be reclaimed" rule
Log.swift os.Logger categories
Resources/Info.plist The bundle's plist, copied in when the .app is assembled: the scheme, LSUIElement, the minimum system version
Preview/main.swift The preview tool's cases — every state a view can reach

Link routing — the traps

  • The scheme must be declared in Info.plist at build time (CFBundleURLTypesCFBundleURLSchemesclaude). macOS silently lets an app register as the handler for a scheme it never declared, and then delivers nothing to it. This was established experimentally, not read in a document.
  • Open the chosen copy by bundle URL (NSWorkspace.open(_:withApplicationAt:configuration:)). An explicit open bypasses the scheme's default handler, so the link never comes back around to you and cannot loop.
  • The link queue and raising the panel. Queue links FIFO. Raise the panel for every incoming link, not only when the queue was empty. Trap. The "raise only on an empty queue" condition wedges the agent for good: the moment the panel goes away while the queue still holds something, every further link piles up silently behind a window nobody can see. Showing an already-visible panel for the same link must be harmless — rebuild nothing, so the highlight the user is aiming with does not reset.
  • An NSPanel hides itself when its app deactivates (hidesOnDeactivate). For a background agent that hardly ever holds focus, that means the window vanishes at the first click elsewhere. Set hidesOnDeactivate = false.
  • Links get clicked from full-screen apps. Give the panel collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary].
  • The panel must never open underneath the cursor. It takes focus, and the user's next click — aimed at whatever they were doing — lands on a row of the list. This looked exactly like "the app is choosing a copy by itself" and cost an hour of investigation. Placing it beside the cursor is not enough on its own: near a screen edge the panel is pushed back inside the screen and ends up under the pointer anyway. Put the placement in a pure function that tries the four corners around the cursor and takes the first one that clears it; it needs tests, including "the pointer is at the bottom edge of the screen".
  • A borderless panel must be able to become key: subclass NSPanel with canBecomeKey = true and a [.borderless, .nonactivatingPanel] style mask.
  • The order of the copies is fixed — by name, then by identifier. Not by frequency of use: the window appears every time, and a list that reshuffles will one day send a link to the wrong place because the hand has learned to press "2".
  • Show the bundle identifier on every row. Both copies are called "Claude"; the identifier is the only thing that tells them apart.

The default scheme handler

  • A button in the menu makes the app the handler (NSWorkspace.setDefaultApplication(at:toOpenURLsWithScheme:)). Do not do this without the user's explicit consent — it changes routing for the whole machine.
  • Pressing the button also records the intent in UserDefaults. On every launch the agent checks: intent recorded but the scheme gone → take it back and write scheme lost since last launch — reclaiming to the log. A separate toggle turns that off. The rule itself ("should it reclaim") is a pure function with tests. Why. Re-registering any app that claims the scheme through lsregister makes the system choose a handler again, and it never chooses ours. But not every rebuild drops the binding — this is a safety net, not a mandatory step.
  • Refresh the handler status when the app activates: LaunchServices posts no public notification when the default changes.
  • How to test reclaiming honestly. Hand the scheme to another app by hand, restart the agent, and check that the reclaim line appears in the log. "I rebuilt and the scheme stayed" proves nothing — the case you care about may simply not have happened.

Plan limits

Where the numbers come from. GET https://api.anthropic.com/api/oauth/usage with Authorization: Bearer <token> and anthropic-beta: oauth-2025-04-20. It is the same undocumented endpoint Claude Code's own /usage view calls. There are no compatibility guarantees, and with ANTHROPIC_API_KEY, Bedrock or Vertex there are no plan limits at all. A 401 or 403 means "signed out"; other codes are plain HTTP errors.

What it answers. Only two branches of the payload matter, and both may be absent:

{
  "five_hour": { "utilization": 85.0, "resets_at": "2026-07-27T19:30:00.123456Z" },
  "seven_day": { "utilization": 56.0, "resets_at": "2026-07-31T08:00:00.123456Z" }
}

utilization is a percentage from 0 to 100, arriving as a double even when whole ("14.0") — round it for display rather than printing the fraction. resets_at is stamped to microsecond precision, which the plain internet-date parser rejects: try ISO8601DateFormatter with .withFractionalSeconds first and fall back to the plain one. Treat a payload with neither window as malformed. Verify the shape against a live reply before building on it, and only after checking with the user — the call spends their token.

How a copy is tied to its profile. Read the first 8 KB of the bundle's executable. If it is a script with a shebang, pull CLAUDE_CONFIG_DIR out of it; if it is a real binary, the profile is the default one, ~/.claude. Do not read the whole file: a real binary is hundreds of megabytes.

The keychain service name (derived experimentally and verified):

Claude Code-credentials-<first 8 hex digits of sha256(absolute path of the config dir)>

The default profile ~/.claude lives in the item without a suffix. Normalize the path before hashing: a trailing slash changes the hash and therefore the item's name.

Trap. The hash is over the absolute path, so the suffix depends on the account name and differs from machine to machine. Do not carry a suffix over from someone else's notes; work out this machine's yourself and check it against the keychain:

printf '%s' "$HOME/.claude-work" | shasum -a 256 | cut -c1-8

Pin the naming rule with a test, but write the test against a synthetic home (/Users/tester/.claude-work) rather than the real one, or it fails for the next person who runs it.

What reads the token. A call to /usr/bin/security find-generic-password -s <name> -w, not the Security framework. The item holds JSON, not a bare token. What comes back looks like {"claudeAiOauth":{"accessToken":"…", …}} — decode it and send only accessToken in the Authorization header. Handing the whole blob over gets a 401, which then reads as "signed out" and sends you looking in the wrong place. Trap. Claude Code creates these items with the same tool, so the item's access list already trusts security and the read goes through unprompted. A direct SecItemCopyMatching would come from your bundle instead, whose ad-hoc signature changes on every rebuild — so macOS would ask for permission again after each one.

Do not read the numbers from the cache. ~/.claude.json (for the default profile) and <config dir>/.claude.json hold cachedUsageUtilization, but only the app refreshes it and in practice it lags by days — in the original investigation the cache was eleven days old. Fetch live: on launch, every ten minutes, when the menu opens, and on demand. Do not refetch an answer younger than a minute, so a menu opened twice does not mean two round trips.

A detail about the default profile. Its root config is ~/.claude.json, not ~/.claude/.claude.json. Setting CLAUDE_CONFIG_DIR="$HOME/.claude" explicitly makes the CLI read the latter, where oauthAccount is empty.

If you need to look at the keychain items yourself, security dump-keychain may well be blocked for you — whether it is depends on how your session is sandboxed. Do not work around the block — ask the user to run the command and send the output back (it is service names only, no passwords):

security dump-keychain 2>/dev/null | grep -o '"svce"<blob>="[^"]*"' | sort -u | grep -i claude

The pace arithmetic

A limit window refills whole when it resets, so an even burn spends a hundred percent over exactly the window's own length. That gives three readings the bare percentage does not:

  • Rate — how many times faster than even the spending goes: (spent/100) / (elapsed/window). Exactly on it is 1.0×. Past tenfold, write 10×+: that is a burst, not a rate.
  • Time left — how long the untouched part of the limit lasts at that speed. Report it only when it runs out before the reset; otherwise the window refills anyway and the forecast would be about a limit that no longer exists.
  • Slack — the difference between how long the untouched part would last at an even burn and how long the window has left to run. Negative slack means the limit runs out before the reset.

Thresholds: slack under a tenth of the window is "tight"; negative slack is "over". With less than a hundredth of the window elapsed, report no rate — there is too little to divide by. A reset in the past means the server has not rolled the window yet: treat the remaining time as zero. A reply with no reset gets a bar with no tick, no note and a neutral tint.

Pin the thresholds with tests that a mutant cannot survive: assert the one-tenth boundary itself, not just values either side of it.

The UI

  • The menu-bar dropdown is a panel, not a list. .menuBarExtraStyle(.window) — a plain menu can hold only rows of text, and the limits are worth drawing.
  • The trap that cost a whole round of fixes: a ScrollView inside the self-sizing MenuBarExtra window silently renders nothing. The window sizes itself to its content, and a ScrollView has no height of its own to offer — the result is an empty gap where the cards should be. Lay the cards out in a plain VStack. Scrolling is not needed anyway: a handful of copies of Claude fits on any screen.
  • MenuBarExtra has no reliable "menu opened" hook. In window style the content is rebuilt on every show — onAppear is the hook.
  • Start with large type. In the original work the user twice reported "too small" and "the text under the charts is very small". Working values: panel width 400 pt, copy name 15, identifier 12, window labels 13, percentages 14, the note under a bar 13, bar height 10 pt, copy icons 28 pt. Picker: width 400, title 19, name 15.5, icons 32, number badges 24.
  • minimumScaleFactor, lineLimit and truncation are a last resort, not a way to make text fit. When something does not fit, change the layout — padding, width, wording. Keep the fallback for the residual case only.
  • While the panel is open, redraw the notes every half minute with TimelineView(.periodic(...)), so the countdowns do not go stale under the cursor. Wrap only the time-dependent part, or the whole list rebuilds with it.
  • The bar: the fill is how much of the window is spent; the tick is where an even burn would have the fill by now (draw it last and taller than the track); the colour follows the same reading — green, amber, red. Keep one line of explanation in the panel: without it the tick means nothing.

The menu-bar icon

  • The icon signals through silhouette, never colour. The menu bar is see-through, the wallpaper sits behind the glyph, and a red warning on a red wallpaper is no warning at all. Keep every state a template image (isTemplate = true) — then the system guarantees the contrast: black under a light appearance, white under a dark one.
  • The identifying glyph stays in every state. Swapping the whole icon for a warning triangle was tried and failed: a triangle among a row of menu-bar items could belong to anything, and the app stops being recognisable. Put the warning in a corner badge instead: a hollow exclamation mark for "this window is nearly used up on schedule", a solid one for "the limit runs out before the reset".
  • Shrink and shift the main glyph a little so the badge sits beside its stem rather than through it — the first attempt ate the stem and the icon read as two loose arrows.
  • Clear a ring of canvas around the badge. Once the system tints them, both shapes are the same colour, and without a gap they read as a single smudge.
  • Draw the badge with a heavier symbol configuration: at roughly 9 pt a regular weight thins out.
  • Give the icon an accessibility label, different for each of the three states.
  • Inside the panel colour is fine — it has a background of its own.

The log

A background agent has no window to report into. Write to the unified log through os.Logger, with a subsystem like dev.<prefix>.claude-selector and the categories router and usage. A healthy round trip for one link is four lines: incoming …showing picker for …panel … visible=true key=trueopening … in <bundle id>.

The panel-state line is mandatory and it matters: a "showing picker" line only proves the presentation code ran, not that the window reached the screen. "The panel was never built" and "it was built but never became visible" are two different faults, and without the measurement you cannot tell them apart.

Reading the log (the info level is required):

log show --last 10m --info --predicate 'subsystem == "dev.<prefix>.claude-selector"' --style compact

How to look at the UI

This is the single most important working rule for this project. In the original history the user acted as the agent's display three times: "nothing appeared", "I didn't see the window when you said you were testing", "I don't see the charts in the menu". Every time the build was green, the tests passed and the process relaunched.

  • The tests cover the arithmetic and never reach a view. Relaunching the app proves the process starts, not that it drew anything.
  • No screen-control tool can see this app. If you have one at all — you may not; a terminal session has none — it will fail to find the app: an app built into a project directory is not in the Spotlight index, so a lookup by display name or bundle identifier answers "not installed". On the machine this was written on, two rounds of computer-use request_access calls went to waste that way. Do not ask for a screenshot of the running app; render the preview below instead.
  • Build a preview subcommand on day one. It compiles the real views (swiftc over the view sources plus a small main.swift) and renders them to a PNG through ImageRenderer. Show every state at once: calm, tight, over, a reply with no reset, loading, error — in light and dark, with a strip of menu-bar icon states above them over several backdrops (grey, red, blue) and a separate row at triple size, because the badge is tiny at its real size.
  • Open the resulting image and look at it before claiming a UI change works.
  • Add a case to the preview whenever a new state can reach the screen.
  • State the boundary honestly: the chrome around the gauges in the preview is a stand-in — the real panel hangs off the store, which scans LaunchServices and the keychain. The preview answers questions about bars, wording and type sizes, not about the panel's own plumbing.

The build script

One build.sh with subcommands. No ANSI colours (respect NO_COLOR).

Subcommand What it does
prod Release build, assemble the .app by hand, ad-hoc sign, lsregister -f
check The gate before a commit: debug build → leftover-marker scan → strict format check → tests → rebuild and relaunch the agent
test The test suite, optionally filtered
preview Render the UI to a PNG
dev swift run
fmt Format in place

Details:

  • Assemble the .app by hand: mkdir Contents/MacOS Contents/Resources, copy the binary, copy Info.plist. SwiftPM does not do bundles.
  • Info.plist needs LSUIElement = true (an agent with no Dock icon), LSMinimumSystemVersion and CFBundleURLTypes with the scheme.
  • Run lsregister -f after the build: LaunchServices only offers a handler it has already seen.
  • Between pkill and open, wait for the old process to actually go. LaunchServices refuses to launch an app it still believes is running, and the relaunch fails. Poll pgrep in a short loop.
  • Skip the relaunch step when CI is set. Not only for headless environments: relaunching the agent triggers live API calls with the user's tokens. If you are working for a long stretch, say so beforehand rather than afterwards.
  • The format check is strict (swift format lint --strict). Run fmt before check, or a formatting failure costs the whole run, rebuild and relaunch included.
  • Include TODO, FIXME, HACK, XXX and swift-format-ignore in the leftover-marker scan.
  • Formatting and the format check must cover the preview directory too, or it drifts out of the common style.

The inner loop is swift build && swift test — seconds, and enough for everything below the view layer. The full check belongs before a commit only. In the original session twelve full check runs were spent verifying, among other things, one-line edits; that is pure waste.


Language and documentation

  • Interface strings are Russian. Code, comments, log lines, README.md and commit messages are English. A diagnostic line meant for the log stays English even when a screen-facing sibling sits next to it.
  • Numbers inside the Russian interface use a comma: 1,7×, not 1.7×.
  • Write AGENTS.md up front, not after the third session. In the original history three sessions in a row rediscovered the same things: that the gate is ./build.sh check, that fmt comes before it, that there are two profiles, that no screen-control tool can find the app. AGENTS.md should hold the commands and which loop each belongs to, how to look at the UI, the UI rules and how the environment is put together.
  • README.md tells a person what the app does and why; AGENTS.md tells an agent how to work on it. Do not duplicate one inside the other.
  • Comments must explain why, not restate the code. Every trap from this prompt that ends up in the code deserves a comment where it lives — otherwise the next edit brings it back.

Tests

Cover the pure logic, not the views:

  • parsing CLAUDE_CONFIG_DIR out of the wrapper: quotes, ~, $HOME, a same-line comment, an unexpandable variable, a real binary;
  • the keychain service name against a synthetic home, both the bare name for the default profile and a suffixed one, plus trailing-slash normalization;
  • decoding a real endpoint response, including microsecond timestamps and a payload missing a window;
  • the picker's headline for each shape of link Claude sends — an action (claude://resume), a host with a path, an action with a query;
  • the pace arithmetic: a reset in the past, a reply with no reset, a just-reset window, the "tight" boundary exactly at one tenth;
  • picking the worst standing across several copies;
  • window placement: all four corners, the cursor at each screen edge, a window larger than the screen;
  • key handling in the picker: digits 19 and 0, arrows wrapping around, esc;
  • badge names and rendered pixels — a name proves nothing if compositing loses the badge.

A useful habit: probe the tests with mutants. Flip a threshold, a comparison, a boundary — if no test goes red, it is guarding nothing.


Process and discipline

  1. Study the environment before writing code. Query LaunchServices, look at both copies, read the wrapper. Half the traps above are properties of this particular machine, not general rules.
  2. Never make the app the handler for a system scheme without the user's explicit consent. It changes routing for the whole machine.
  3. Warn before actions that spend someone else's money or quota. Relaunching the agent hits the API with the user's tokens.
  4. Do not trust your own log line saying the window appeared. Measure.
  5. Do not pass a green build off as a working UI. Look at the image.
  6. If another session is working in the same directory, check git diff --cached --stat before committing and commit by explicit paths, or you will take someone else's unfinished work with you. When the file is shared and paths cannot separate the two, unstage everything and wait for the other session to finish.
  7. Add diagnostics for a specific hunt and remove them when the hunt is over. Keep only the meaningful lines permanently.

Definition of done

  • The second copy of Claude Desktop launches, runs against its own profile, and shows its own name; claude auth status for the two profiles gives different accounts.
  • The two copies are told apart at a glance in the Dock and in Cmd+Tab — the copy carries an icon of its own, not the original's.
  • codesign --verify --strict passes for the copy.
  • The selector builds, signs, registers and sits in the menu bar.
  • Once it is the handler, every claude:// link raises the picker, and the log shows the full four-line round trip including visible=true key=true.
  • The panel never lands under the cursor and does not vanish on losing focus — verified with the pointer at the bottom edge of the screen.
  • Several links in a row are handled one by one and none is lost.
  • Choosing a copy opens the link in it and does not hand it back to the handler.
  • Under each copy there are two bars with live numbers and a note reading темп … хватит … сброс ….
  • The menu-bar icon reads on light, dark, red and blue backdrops in all three states, and the app is still recognisable by it.
  • ./build.sh check passes end to end; ./build.sh preview draws every state, and the image has been looked at.
  • README.md and AGENTS.md are written, and the traps are recorded as comments in the code.

What to ask before starting

Ask these in one message and wait for the answers.

  1. Which identifier prefix should be used (dev.<who>.claude-work, dev.<who>.claude-selector)?
  2. Where is the original Claude Desktop, and where should the copy go?
  3. Which Claude Code profiles already exist on this machine, and which is the work one? If the second one does not exist yet, is there a second Anthropic account with a plan to sign into it, and should I create the profile?
  4. May the selector be made the default handler for claude:// once it is ready?
  5. Is it acceptable that the app calls api.anthropic.com with tokens from your keychain on launch and every ten minutes?
  6. Is the interface language Russian, as in the original app, or something else?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment