Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ just switch

| | 🍎 🐧 Both | 🍎 macOS only | 🐧 Linux only |
|---|---|---|---|
| **AI** | [codex](https://github.com/openai/codex) · [claude-code](https://github.com/anthropics/claude-code) (not Intel macOS) · claude-profile · [claude-desktop](https://claude.ai/download) · [pi](https://pi.dev/) · [apm](https://microsoft.github.io/apm/) · [ccstatusline](https://github.com/sirmalloc/ccstatusline) · [paperclip](https://paperclip.ing) · [orca](https://github.com/stablyai/orca) · [agent-access](https://github.com/bitwarden/agent-access) · [bitwarden-cli](https://bitwarden.com/help/cli/) · [grok-bot](https://x.ai/bot) | — | — |
| **AI** | [codex](https://github.com/openai/codex) · [claude-code](https://github.com/anthropics/claude-code) (not Intel macOS) · claude-profile · [claude-desktop](https://claude.ai/download) · [pi](https://pi.dev/) · [apm](https://microsoft.github.io/apm/) · [ccstatusline](https://github.com/sirmalloc/ccstatusline) · [paperclip](https://paperclip.ing) · [orca](https://github.com/stablyai/orca) · [agent-access](https://github.com/bitwarden/agent-access) · [bitwarden-cli](https://bitwarden.com/help/cli/) · [grok-bot](https://x.ai/bot) · [cua-driver](https://github.com/trycua/cua/tree/main/libs/cua-driver) | — | — |
| **Terminal** | [kitty](https://sw.kovidgoyal.net/kitty/) · [zellij](https://zellij.dev/) · [zoxide](https://github.com/ajeetdsouza/zoxide) · [yazi](https://github.com/sxyazi/yazi) · [ripgrep](https://github.com/BurntSushi/ripgrep) · [nix-zsh-completions](https://github.com/nix-community/nix-zsh-completions) | — | — |
| **Editors** | [intellij-idea-ultimate](https://www.jetbrains.com/idea/) · [cursor](https://cursor.com/) | [jetbrains-air](https://air.dev/) · [pulsar](https://pulsar-edit.dev/) | — |
| **Dev** | [git](https://git-scm.com/) · [git-open](https://github.com/paulirish/git-open) · [gh](https://cli.github.com/) · [glab](https://gitlab.com/gitlab-org/cli) · [docker](https://www.docker.com/) (client) · [docker-compose](https://docs.docker.com/compose/) · [asdf](https://asdf-vm.com/) (go, nodejs, java, maven) · [pnpm](https://pnpm.io/) · [python3](https://www.python.org/) (ipython, pip) · [rust](https://www.rust-lang.org/) (cargo, rust-analyzer, clippy, rustfmt) · [ruby](https://www.ruby-lang.org/) · [just](https://just.systems/) · [android-sdk](https://developer.android.com/tools) · [lark-cli](https://www.npmjs.com/package/@larksuite/cli) | [colima](https://github.com/abiosoft/colima) · [cocoapods](https://cocoapods.org/) | — |
Expand Down Expand Up @@ -138,6 +138,7 @@ line-level and fails loudly if either doesn't match.
- **asdf pins Go / Node / Java / Maven** to explicit versions in `apps/asdf/default.nix`. Python comes from nixpkgs instead, since asdf compiles it from source.
- **Docker on macOS is colima.** A launchd agent starts the VM at login; resize it with `colima stop && colima start --cpu 4 --memory 8`. On Linux the daemon is a system service, out of scope here.
- **Tailscale on macOS** is the standalone app. Open it after switching, then approve its VPN configuration and system extension when prompted. On Linux, the package provides `tailscale` and `tailscaled`; the host must run the daemon as a system service before `tailscale up` can connect.
- **cua-driver** is the MIT-licensed computer-use driver from [nur-packages](https://github.com/NoSugarCoffee/nur-packages). A switch installs the CLI and links its skill pack at `~/.claude/skills/cua-driver`. It does not register MCP servers: that list belongs to [dotai](https://github.com/NoSugarCoffee/dotai) via APM (`apm install -g --target claude,codex --mcp cua-driver -- cua-driver mcp`). On macOS the signed `CuaDriver.app` is copied to `/Applications/CuaDriver.app` (a real directory, not a symlink to the store), the path `cua-driver mcp` relaunches. Only the `cua-driver` binary is on PATH, so the bundle is not also installed under `~/Applications/Home Manager Apps`. An existing `/Applications/CuaDriver.app` without the home-manager stamp is left in place. After the first switch, grant that app Accessibility and Screen Recording under System Settings → Privacy & Security, and grant them again if a later pin changes its code signature. `cua-driver skills install` still links the same pack into any other detected agent.
- **Ad-hoc-signed macOS apps are re-signed locally.** Nix builds like kitty and Albert carry a signature that changes with every rebuild, which silently invalidates Accessibility / Screen Recording grants. A switch re-signs their copies in `~/Applications/Home Manager Apps` with a self-signed `dotnix local code signing` certificate it creates in the login keychain, so grants survive updates. The first switch that migrates an app to this identity still changes its code identity once -- macOS treats that as a new app, so expect to re-grant Accessibility / Screen Recording for kitty and Albert one more time right after that switch; it then sticks across every rebuild after. Without a login keychain at `~/Library/Keychains/login.keychain-db` (headless activation, CI) it warns and leaves the copy ad-hoc signed instead of failing.
- **Packages nixpkgs lacks** (the macOS repacks of OBS, Clash Verge, Pulsar, Albert, …) come from [NoSugarCoffee/nur-packages](https://github.com/NoSugarCoffee/nur-packages), which follows each upstream release 4× a day and serves builds from `nosugarcoffee.cachix.org` on Apple Silicon and Linux (Intel Macs build them locally against the 26.05 pin); AI agents come from [numtide/llm-agents.nix](https://github.com/numtide/llm-agents.nix), which has no Intel Mac builds. Neither needs anything here beyond `nix flake update`.
- **China mirrors.** `scripts/bootstrap-macos.sh` adds SJTU/TUNA/USTC substituters; check with `nix config show | grep substitut`.
Expand Down
121 changes: 121 additions & 0 deletions apps/cua-driver/default.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
{
config,
lib,
pkgs,
homeDirectory,
...
}:
let
# The signed release lives in NoSugarCoffee/nur-packages. This module only
# puts the CLI on PATH and copies the macOS bundle to the path `mcp`
# relaunches. Claude Code's MCP server list is owned by dotai (APM), not
# by a switch.
cua-driver = pkgs.nosugarcoffee.cua-driver or null;

# The package also ships $out/Applications/CuaDriver.app. home.packages
# feeds copyApps, which links every package's /Applications into
# ~/Applications/Home Manager Apps. This nixpkgs buildEnv lndirs the
# whole package and does not honor pathsToLink, so a symlinkJoin would
# still expose the bundle. Link only the CLI. The wrapper script execs
# the store bundle or /Applications/CuaDriver.app by absolute path.
cuaDriverBin =
if cua-driver == null then
null
else
pkgs.runCommand "cua-driver" { } ''
mkdir -p "$out/bin"
ln -s "${cua-driver}/bin/cua-driver" "$out/bin/cua-driver"
'';
in
{
home = {
packages = lib.optional (cuaDriverBin != null) cuaDriverBin;

file = lib.mkIf (cua-driver != null) {
# The driver's own skill directory, and the Claude Code link. The
# Claude link targets the home copy so `cua-driver skills install`
# sees one pack.
".cua-driver/skills/cua-driver".source = cua-driver.skills;
".claude/skills/cua-driver".source =
config.lib.file.mkOutOfStoreSymlink "${homeDirectory}/.cua-driver/skills/cua-driver";
};

activation = {
# A real directory, not a symlink: TCC grants and the relaunch are
# keyed to /Applications/CuaDriver.app. Ownership matches copyApps:
# `home-manager.copied` on the bundle directory. A bundle without
# that stamp (the upstream installer) is left alone.
cuaDriverApp = lib.mkIf (cua-driver != null && pkgs.stdenv.isDarwin) (
lib.hm.dag.entryAfter [ "copyApps" ] ''
sourceApp=${lib.escapeShellArg "${cua-driver}/Applications/CuaDriver.app"}
dest=/Applications/CuaDriver.app
destParent=$(dirname "$dest")
staging="$destParent/.CuaDriver.app.hm-new"
marker=${lib.escapeShellArg "${config.xdg.stateHome}/home-manager/cua-driver-app-source"}

# Same sentinel as copyApps in home.nix. An xattr on the .app
# directory, not a file inside the bundle (that would break the
# signature). The hidden file is the non-Darwin stand-in.
copySentinelAttr=home-manager.copied
copySentinelFile=.home-manager-copied

hasCopySentinel() {
local app="$1"
if [[ "$(uname -s)" == Darwin ]]; then
/usr/bin/xattr -p "$copySentinelAttr" "$app" >/dev/null 2>&1
else
[[ -f "$app/$copySentinelFile" ]]
fi
}

writeCopySentinel() {
local app="$1" value="$2"
if [[ "$(uname -s)" == Darwin ]]; then
/usr/bin/xattr -w "$copySentinelAttr" "$value" "$app"
else
printf '%s' "$value" > "$app/$copySentinelFile"
fi
}

if [[ ! -w "$destParent" ]]; then
warnEcho "cua-driver: $destParent is not writable, so CuaDriver.app was not copied. mcp relaunches /Applications/CuaDriver.app; grant write access there and switch again."
elif [[ -e "$dest" ]] && ! hasCopySentinel "$dest"; then
warnEcho "cua-driver: $dest already exists and was not placed by home-manager; leaving it in place"
elif [[ -d "$dest" && "$(readlink "$marker" 2>/dev/null || true)" == "$sourceApp" ]]; then
:
elif [[ -v DRY_RUN ]]; then
echo "would copy $sourceApp to $dest" >&2
else
$DRY_RUN_CMD mkdir -p "$(dirname "$marker")"
$DRY_RUN_CMD rm -rf "$staging"
# The store bundle is mode 555. ditto keeps that, and xattr/rm then
# fail on the staged copy (macOS CI aborted the whole switch).
# Owner can chmod a 555 tree; the signature hashes contents, not mode.
if ! $DRY_RUN_CMD /usr/bin/ditto "$sourceApp" "$staging"; then
warnEcho "cua-driver: copying CuaDriver.app to $destParent failed"
elif ! $DRY_RUN_CMD /bin/chmod -R u+w "$staging"; then
warnEcho "cua-driver: could not make the staged CuaDriver.app writable"
$DRY_RUN_CMD rm -rf "$staging" || warnEcho "cua-driver: could not remove $staging"
elif ! $DRY_RUN_CMD /usr/bin/codesign --verify --deep --strict "$staging"; then
warnEcho "cua-driver: copied CuaDriver.app failed signature verification; the previous app was left in place"
$DRY_RUN_CMD rm -rf "$staging" || warnEcho "cua-driver: could not remove $staging"
elif ! writeCopySentinel "$staging" "$sourceApp"; then
warnEcho "cua-driver: failed to stamp CuaDriver.app; leaving the previous app in place"
$DRY_RUN_CMD rm -rf "$staging" || warnEcho "cua-driver: could not remove $staging"
else
# Only reached when $dest is missing or already stamped.
# The running daemon holds the old binary.
if [[ -d "$dest" ]]; then
$DRY_RUN_CMD /usr/bin/pkill -x cua-driver || true
$DRY_RUN_CMD rm -rf "$dest"
fi
$DRY_RUN_CMD mv "$staging" "$dest"
writeCopySentinel "$dest" "$sourceApp"
$DRY_RUN_CMD ln -sfn "$sourceApp" "$marker"
fi
fi
''
);
};
};
}
6 changes: 3 additions & 3 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions home.nix
Original file line number Diff line number Diff line change
Expand Up @@ -317,9 +317,15 @@

# A crash can leave an owned bundle with no sidecar. The sidecar
# loop above would miss it; sweep /Applications for our sentinel.
# CuaDriver.app is stamped with the same xattr by apps/cua-driver,
# which copies it to /Applications itself. Only its bin is on PATH,
# so it is never in $sourceFolder; this sweep must not remove it.
for app in "$systemFolder"/*.app; do
[[ -e "$app" ]] || continue
name=$(basename "$app")
if [[ "$name" == "CuaDriver.app" ]]; then
continue
Comment on lines +326 to +327

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clean up CuaDriver when its package disappears

When a future NUR update stops exposing pkgs.nosugarcoffee.cua-driver for a Darwin system, the module's package and activation are disabled by cua-driver == null, but this unconditional exception prevents copyApps from removing the previously stamped bundle. The old app can therefore remain installed—and retain Accessibility and Screen Recording grants—despite no longer being declared; gate this exception on package availability or add an absent-package cleanup path for stamped copies.

AGENTS.md reference: AGENTS.md:L29-L30

Useful? React with 👍 / 👎.

fi
if hasCopySentinel "$app" && [[ ! -e "$sourceFolder/$name" ]]; then
echo "removing $name from $systemFolder..." >&2
run rm -rf "$app"
Expand Down
18 changes: 18 additions & 0 deletions tests/test_copy_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,24 @@ def test_cleanup_removes_sentinel_without_sidecar_when_package_gone(self) -> Non
self.assertFalse(orphan.exists())
self.assertTrue((self.hm / "Other.app").exists())

def test_cua_driver_stamp_in_applications_is_not_swept(self) -> None:
# apps/cua-driver owns /Applications/CuaDriver.app and stamps it.
# copyApps must not treat that stamp as a leftover system-extension app.
self.make_app(self.source / "Other.app", ext=False, bundle_id="com.example.other")
kept = self.make_app(self.apps / "CuaDriver.app", ext=False, bundle_id="com.trycua.driver", payload="OURS\n")
self.stamp(kept, "/nix/store/cua-driver/Applications/CuaDriver.app")
duplicate = self.make_app(
self.hm / "CuaDriver.app",
ext=False,
bundle_id="com.trycua.driver",
payload="HOME-MANAGER-APPS\n",
)
result = self.run_copyapps()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(kept.is_dir())
self.assertEqual((kept / "Contents" / "MacOS" / "app").read_text(), "OURS\n")
self.assertFalse(duplicate.exists())


if __name__ == "__main__":
unittest.main()
Loading
Loading