Providers are optional — Toki shows what is installed and authenticated. Claude Code plus claude-swap covers multi-account Claude workflows; Codex, Pi, Sarvam Code, OpenCode, Copilot CLI, Gemini CLI, and Grok CLI are each picked up when present.
swift build
swift run Toki
scripts/build-app.sh # bundle to .build/Toki.app
scripts/install-app.sh # bundle and install to ~/Applications
Before shipping a local change:
swift build
swift test
scripts/build-app.sh
plutil -p .build/Toki.app/Contents/Info.plist
The TokiWidgets.appex extension is embedded under Contents/PlugIns by both build scripts. Toki and the extension are separate sandboxed processes, so they share a snapshot (widget-data.json) through one of two channels, chosen at package time by TokiWidgetDataMode in each Info.plist:
app-group — used only when APPLE_SIGNING_IDENTITY is set. The snapshot lives in the group.com.aashutoshrathi.toki App Group container. This requires the App Group to be enabled for both the app and the widget-extension signing identifiers in your Apple Developer configuration. If it isn’t provisioned, the extension can’t read the container and every widget stays empty — there is no runtime fallback.local — used for ad-hoc/unsigned builds (no APPLE_SIGNING_IDENTITY). The snapshot goes to ~/Library/Application Support/Toki/widget-data.json, and the extension is signed with Config/TokiWidgets.local.entitlements, a scoped read-only exception to that one folder. No Team ID or App Group provisioning is needed, so this mode is self-contained and portable.Because the public DMG is ad-hoc signed (not notarized), released builds ship in local mode. The one caveat that carries to another Mac is Gatekeeper: the appex inherits the same “unidentified developer” friction as the app, so the quarantine flag must be cleared from the whole bundle for the extension to load and register — xattr -dr com.apple.quarantine (note -r, recursive) does this; clearing only the top level leaves the appex quarantined and the widget absent from the gallery. Test on a second Mac or a fresh user account before relying on it, since WidgetKit registration for ad-hoc extensions is not something Apple formally supports.
When a widget looks wrong:
tokiWidgetStaleAfter), or widget-data.json is missing/unreadable. Launch Toki and let it refresh once.pluginkit -mv | grep toki; if absent, re-clear quarantine recursively and relaunch the app.Every release starts from a tag push; .github/workflows/release.yml builds the DMG and publishes the GitHub release. The tag decides the channel:
vX.Y.Z (e.g. v2.5.0). Published as a full GitHub release, offered to everyone, and both Homebrew casks are updated (toki and toki-beta).v2.5.0-beta.1). Published as a GitHub prerelease, offered only to users who picked the Beta channel in Settings > Updates, and the toki-beta cask is updated. The stable cask is not touched.Set appVersion in Sources/Toki/Config/Constants.swift to match the tag’s base version (without the v and without any prerelease suffix) before tagging — the packaging script, the in-app updater’s version check, and the DMG verification all read it. CFBundleShortVersionString must stay dotted-numeric; the prerelease identity travels in the tag, which the release workflow stamps into TokiReleaseVersion in Info.plist. The DMG filename is built from the base version (Toki_2.5.0_universal.dmg) even for a beta tag.
To graduate a beta to production:
v2.5.0-beta.1 (with appVersion = "2.5.0") and test on the Beta channel. Iterate with -beta.2, -beta.3, … as needed.appVersion = "2.5.0" and tag v2.5.0. That build ships to everyone: stable users see it as a normal update, and beta users are offered it too, because 2.5.0 outranks 2.5.0-beta.N — so testers land back on the production build without touching their channel setting. The same graduation happens for brew: the stable tag bumps both casks, so brew upgrade carries toki-beta users onto the stable build.Version ordering is semver-aware (2.4.3 < 2.5.0-beta.1 < 2.5.0-beta.2 < 2.5.0); the logic and its tests live in UpdateChecker.compareVersions and Tests/TokiTests/UpdateChannelTests.swift. Homebrew’s own ordering agrees, which is what makes the single toki-beta cask work across iterations and graduation.
Two casks live in the tap (aashutoshrathi/homebrew-tap): toki (stable) and toki-beta (prereleases). They conflict with each other — install one.
Homebrew refuses to load a cask from a third-party tap until it is trusted, and it records trust per cask rather than per tap, so brew trust --cask aashutoshrathi/tap/toki is part of installing and installing toki does not cover toki-beta. Switching channels in the app trusts the cask it is moving to for you, naming that one cask so nothing else in the tap is trusted along with it.
homebrew/toki.rb and homebrew/toki-beta.rb in this repo are the cask bodies. The release workflow copies the matching one into the tap on every release and then stamps it, so a change anywhere in a cask is made here, never in the tap: scripts/update-cask.sh rewrites only version and sha256, and anything edited straight in the tap is overwritten at the next release.
A prerelease tag copies and stamps toki-beta alone. The stable cask is deliberately left untouched on that path, because its body would arrive carrying the template’s placeholder version with nothing to stamp a real one over it.
scripts/update-cask.sh <cask.rb> [version] rewrites a cask’s version and DMG sha256. The version argument defaults to appVersion; prerelease tags must pass the full version (e.g. 3.2.0-beta.1) because it exists only in the tag. The DMG filename always carries the base version (Toki_3.2.0_universal.dmg), which the script and the cask’s URL handle.
When Toki was installed by a cask, the in-app updater detects it (BrewCask.installedCask — the Caskroom entry is a symlink to the installed bundle) and routes “Install update” through brew upgrade --cask aashutoshrathi/tap/<toki|toki-beta> instead of swapping the DMG underneath brew. This keeps brew’s receipt in sync with the app on disk; the DMG path would desync it, and the next brew upgrade would then clobber the newer build with the cask’s older one. Dev builds and manual installs keep the direct DMG path.
Naming the tap is load-bearing, not decoration. Homebrew refreshes a third-party tap before install/upgrade only every 24 hours unless the argument is an owner/tap/cask, which drops that window to five minutes (Library/Homebrew/utils/auto-update.sh). With a bare toki, an upgrade run in the first day of a release can resolve against a snapshot taken before the release existed, warn Not upgrading toki, the latest version is already installed, and exit 0 — a handoff that succeeds and changes nothing, with the app still offering the update. The updater also treats that outcome as a failure: it runs brew update and retries the upgrade once before reporting, and what it reports carries brew’s own last line plus brew update && brew reinstall --cask ..., since repeating the upgrade only reproduces the no-op.
The channel picker and the installed cask are kept in agreement in both directions. Picking a channel moves a brew install onto the matching cask (fetch, then uninstall, then install — conflicts_with rules out installing over the top, and fetching first means a network failure cannot strand the uninstall with nothing to reinstall). Swapping casks with brew directly moves the channel instead: the cask decides what brew will install, so the preference follows it on the next check. A failed switch reinstalls the cask that was there and puts the preference back.
CI builds with stricter concurrency than a plain swift build, and the difference has broken this project’s CI more than once — a pure static helper on a @MainActor type needs nonisolated, which only the stricter mode catches. Reproduce it locally before pushing:
swift build --build-tests -Xswiftc -strict-concurrency=complete
swift-format is not vendored here. Keep changes compiler-clean, locally scoped, and consistent with the surrounding SwiftUI/AppKit style.
Comments should explain what the code cannot: why a timeout is the value it is, why a coordinate space is not flipped, why an ordering is deliberate. Comments that restate the line below them are noise and get deleted.
Config needed — create ~/.toki/config.json or set TOKI_CONFIG.No credentials found — confirm Claude Code and claude-swap are authenticated and Keychain access was allowed.Claude Code usage unavailable — Anthropic returned no usage for that account. Retry later, or check the account in Claude Code.Codex usage unavailable — confirm codex login has created ~/.codex/auth.json, then refresh.~, or starts with ~/.claude-swap --switch-to <slot> in Terminal to see the underlying error.brew finished but Toki is still <version> — brew ran without replacing the app. The message carries brew’s own last line; brew update && brew reinstall --cask aashutoshrathi/tap/toki (or toki-beta) is the way out, because it refreshes the tap and ignores a receipt that already claims the new version. Check the tap carries the version the app offered before digging further.Beta builds ship in the toki-beta cask — the Beta channel is on but brew installed the stable toki cask, which never carries a prerelease. Re-pick the channel in Settings to move the install; by hand it is brew uninstall --cask toki && brew install --cask toki-beta, since conflicts_with makes a bare install refuse.