Imported from thebrownproject/looped (
docs/repos/rubber-duck/AGENTS.md). Install upstream withnpx skills add thebrownproject/looped --skill rubber-duck. Copyright stays with the author.
Rubber Duck
macOS menu bar voice coding agent — BYO OpenAI API key. Swift 5 / SwiftUI + KeyboardShortcuts SPM.
Commands
open RubberDuck.xcodeproj— Open in Xcodexcodebuild -scheme Commandment -configuration Debug -destination 'generic/platform=macOS' build -derivedDataPath /tmp/rubber-duck-build— Build from CLIxcodebuild -scheme Commandment -configuration Release -destination 'generic/platform=macOS' build -derivedDataPath /tmp/rubber-duck-build— Release buildxcodebuild -scheme Commandment -configuration Debug -destination 'generic/platform=macOS' test -derivedDataPath /tmp/rubber-duck-build— Run testsmake unused— Find unused Swift declarations (Periphery — Knip equivalent for Swift)xcodebuild -scheme Commandment -configuration Debug -destination 'generic/platform=macOS' build -derivedDataPath /tmp/rubber-duck-build && (pkill -x "Rubber Duck" || true) && rsync -a --delete "/tmp/rubber-duck-build/Build/Products/Debug/Rubber Duck.app/" "/Applications/Rubber Duck.app/" && open "/Applications/Rubber Duck.app"— Build, replace installed app, relaunch (avoids stale bundle)
Rebuild Shortcuts
xcodebuild -scheme Commandment -configuration Debug -destination 'generic/platform=macOS' build -derivedDataPath /tmp/rubber-duck-build && (pkill -x "Rubber Duck" || true) && rsync -a --delete "/tmp/rubber-duck-build/Build/Products/Debug/Rubber Duck.app/" "/Applications/Rubber Duck.app/" && open "/Applications/Rubber Duck.app"— Rebuild and replace the installed macOS appcd cli && npm run build && npm link && (pkill -f "duck-daemon|dist/daemon.js" || true)— Rebuild/relink CLI and force daemon restart soduckuses latest code
CLI Commands (cli/)
cd cli && npm install— Install CLI dependenciescd cli && npm run build— Buildduck,duck-daemon, and library exportscd cli && npm run typecheck— TypeScript checkscd cli && npm run lint— Biome checkscd cli && npm run verify:ci— Build + typecheck + lint + help smokecd cli && node dist/daemon.js --verbose— Run daemon in foreground for debuggingcd cli && node dist/cli.js .— Attach current workspace and start streamingcd cli && node dist/cli.js say "list files"— Send prompt to active session
CLI Binary (standalone, no Node required)
make cli-binary— Build standaloneduckbinaries (arm64 + x64) via esbuild+pkg intocli-bin/; producesduck-arm64,duck-x64, and a native-arch copyduck
E2E Tests (require API keys)
echo "sk-..." > /tmp/rubber-duck-live-realtime-test— Set up Swift Realtime live test keymake e2e-swift— Run Swift Realtime full-conversation E2E (requires/tmp/rubber-duck-live-realtime-test)make e2e-cli— Run CLI daemon integration E2E (requiresOPENAI_API_KEYorANTHROPIC_API_KEY)make e2e-smoke— Run CLI shell smoke test (requires API key + built CLI)make e2e— Run all E2E tests
Setup
- Requires Xcode 16+ and macOS 15.2+ SDK
- SPM dependency: KeyboardShortcuts v2+
- API key (OpenAI) stored in macOS Keychain, configured via Settings window
Gotchas
- Audio is streamed at 24 kHz PCM16 mono to the OpenAI Realtime API
- The app runs as a menu bar agent (
LSUIElement = true) — no dock icon or main window. Do not add aWindowGrouporDocumentGroupscene - If UI changes do not appear, you are likely running a stale bundle. Always replace
/Applications/Rubber Duck.appfrom/tmp/rubber-duck-build/Build/Products/Debug/Rubber Duck.appand relaunch from/Applications. - Settings window opening is centralized in
SettingsWindowController.shared.show(); do not use responder-chain selectors likeshowSettingsWindow:for new code paths - Setup checklist state is stored in
AppConfigManager(setupGuideDismissed) and surfaced in menu + Settings > Setup; keep skip/reset behavior non-blocking - HotkeyManager is
@MainActor— removing this will cause KeyboardShortcuts crashes on background threads - Settings changes propagate via
@EnvironmentObject(AppConfigManager) — do not replace with NotificationCenter - Default global hotkeys are Option+D (activate voice agent) and Option+Shift+D (open Settings) — configured via KeyboardShortcuts in
HotkeyManager - CLI uses a local daemon + Unix socket. Default socket path is
~/Library/Application Support/RubberDuck/daemon.sock; if path length is too long, it falls back to$TMPDIR/duck-<hash>.sock. - CLI daemon runtime files:
~/Library/Application Support/RubberDuck/{metadata.json,config.json,duck-daemon.log,duck-daemon.pid,pi-sessions/}. - CLI
followandsayautomatically handle Piextension_ui_requestevents via@clack/promptsand sendextension_ui_responseback through the daemon. - CLI daemon defaults to
gpt-4o-miniwhenOPENAI_API_KEYis set. Override withRUBBER_DUCK_PI_MODELenv var. Thinking defaults toofffor speed; override withRUBBER_DUCK_PI_THINKING. - Swift app connects to
daemon.sockviaDaemonSocketClient(Network.framework NWConnection,@MainActor). If daemon absent the app runs normally — voice tools return an error, workspace switching falls back to 2s polling. - Voice tool calls (
read_file,write_file,edit_file,bash,grep_search,find_files) are executed bycli/src/daemon/voice-tools.tsvia thevoice_tool_calldaemon method. Swift no longer implements these tools locally. - Workspace switching from
duck [path]→ Swift menu bar is instant viavoice_session_changeddaemon push (no polling delay when daemon is running). - CLI binary is not bundled in the app. On first launch
CLIInstallerdownloads the matching-version binary from GitHub Releases (duck-{version}-macos-{arm64|x64}) to~/Library/Application Support/RubberDuck/duck, strips quarantine, and symlinks to/usr/local/bin. On app update the version mismatch triggers an auto-redownload. - The standalone
duckbinary acts as both CLI and daemon — daemon mode is activated whenargv[0]orargv[1]ends induck-daemon(symlink),argv[1]ends in--daemon(pkg binary spawned viaensure-daemon.ts— pkg bootstrap callspath.resolve()on the first user arg, transforming"--daemon"to"/cwd/--daemon"), orargv[2] === "--daemon"(npm dev mode).
Conventions
- Network retries use exponential backoff (1s, 2s, 4s) with max 3 attempts
- Logging goes through
Logger.shared— uselogInfo(),logError(),logDebug()global functions - Bundle ID:
co.blode.rubber-duck
Distribution
make build— Release buildmake cli-build— Build CLI (TypeScript → dist/)make cli-binary— Build standalone arm64 + x64 binaries (esbuild CJS bundle + pkg); outputscli-bin/duck-{arm64,x64}make cli-test— Run CLI tests (passes when no tests are present)make dmg— Build + create DMG (requiresbrew install create-dmg)make notarize— Build + DMG + notarize (requires Apple Developer credentials in env)make clean— Remove build artifacts- Release workflow:
- Commit changes, then tag:
git tag vX.Y.Z - Push branch + tag together:
git push origin main --tags - If push is rejected (remote ahead):
git pull --rebase && git push - GitHub Actions picks up the tag and handles build/sign/notarize/publish automatically
- Commit changes, then tag:
- Secrets (GitHub):
DEVELOPER_ID_CERT_P12,DEVELOPER_ID_CERT_PASSWORD,APPLE_TEAM_ID,NOTARIZE_APPLE_ID,NOTARIZE_PASSWORD - Homebrew template:
homebrew/rubber-duck.rb— copy tomblode/homebrew-taprepo