Thanks for your interest in contributing. This guide covers dev setup, Navi's architecture, and the patterns you'll need to add or evolve features.
This project adheres to the Open Code of Conduct. By participating, you are expected to honor this code.
git clone https://github.com/Affirm/navi.git
cd navi
bash build.sh # Downloads + verifies the published Navi.app release
open Navi.app # LaunchNavi is a SwiftUI app built with Swift Package Manager. Requires macOS + Xcode Command Line Tools (xcode-select --install). The test target depends on the standalone swift-testing package; runtime app code has no third-party dependencies.
bash build.sh is the hook-time install entrypoint: it reads the target version from .claude-plugin/plugin.json, fetches Navi.app.zip from the matching GitHub Release, verifies it against the release's checksums.txt, and (if the gh CLI is installed) also verifies the build-provenance attestation against the Sigstore transparency log. It does not compile from source. Releases are produced exclusively by .github/workflows/release.yml, which builds the same artifact twice on separate macos-15 runners and refuses to publish if the two builds' SHA-256s diverge — that two-build check is what catches reproducibility regressions before they reach users.
To compile locally instead of fetching (e.g., when validating source changes before publishing a release), use either:
bash scripts/build-from-source.sh ./out # produces ./out/Navi.app and ./out/Navi.app.zip
# or, to install in place:
NAVI_BUILD_FROM_SOURCE=1 bash build.shscripts/build-from-source.sh is what CI's release workflow runs, so a successful local build is a strong signal that the eventual release will succeed.
Run unit tests with swift test. Tests live in Tests/NaviCoreTests/ and exercise the NaviCore library target. SwiftUI views and the app shell (the Navi executable target) are not directly unit-tested; put testable logic in NaviCore.
| Path | Purpose |
|---|---|
Package.swift |
SPM manifest: NaviCore library, Navi executable, NaviCoreTests test target |
Sources/NaviCore/ |
Library: models (NaviEvent, SessionInfo, SessionGroup, SessionStatus, GitInfo, TranscriptInfo, PRInfo, SubagentInfo), EventMonitor, FeatureFlags, helpers (relativeTime, focusTerminal, naviLog), naviCurrentVersion, SessionEnrichmentProvider protocol |
Sources/Navi/ |
Executable: @main + NaviAppDelegate, FloatingWindowManager, MenuBarManager, EnrichmentService (conforms to SessionEnrichmentProvider), pastel palette, SwiftUI views (ContentView, SessionSection, EventRow, WindowAccessor, FlowLayout) |
Tests/NaviCoreTests/ |
Swift Testing suites for NaviCore |
hooks/hooks.json |
Registers Claude Code hooks (loaded at session start) |
hooks/hook.sh |
Main hook entrypoint for PermissionRequest, Stop, StopFailure, Notification, PostToolUse, PostToolUseFailure |
hooks/pretooluse.sh |
Lightweight PreToolUse hook — captures tool_use_id for auto-dismiss |
hooks/parse_event.py |
Parses hook payload JSON, writes event/resolve files to /tmp/navi/events/ |
docs/EXTENSION_API.md |
Public contract for external info-event producers — schema, atomicity rules, security constraints |
build.sh |
Hook-time install entrypoint: fetches the published release for the version in plugin.json, verifies checksums.txt + Sigstore attestation, extracts Navi.app |
scripts/build-from-source.sh |
Reproducible-build recipe used by CI and by contributors building locally |
.github/workflows/release.yml |
Builds two artifacts on macos-15, compares SHA-256s, attests provenance, publishes the release |
EnrichmentService lives in the Navi target because it depends on
FloatingWindowManager (toggle state). EventMonitor lives in NaviCore
and needs to call into the service on session updates / evictions, so
NaviCore defines a SessionEnrichmentProvider protocol that
EnrichmentService conforms to. Add new methods to the protocol if
EventMonitor needs to talk to the service in new ways; views in Navi
read concrete service state (gitInfoByCwd, etc.) directly.
- Claude Code fires a hook event (e.g.,
PermissionRequest) hook.shruns → conditionally captures TTY/PPID based on feature flags → callsparse_event.pyparse_event.pywrites a JSON event file to/tmp/navi/events/Navi.appwatches the events directory (kqueue + fallback poll) and displays the event
See the diagram in the README for the full set of hook-to-script mappings.
Experimental features use file-based flags at /tmp/navi/features/<name>. This lets hooks skip work for disabled features without modifying hooks.json or restarting Claude Code sessions.
- Swift side:
FeatureFlags.set(_:enabled:)(inNaviCore) creates/deletes flag files. EachFloatingWindowManagertoggle'sdidSetcalls it, andsyncFeatureFlags()writes all flags on startup. - Hook side: scripts check
[ -f /tmp/navi/features/<name> ]before doing feature-specific work. If the file is absent, the work is skipped.
Two types of flag files:
- Boolean (empty file): created by
FeatureFlags.set(_:enabled:). File exists = enabled, absent = disabled. - Configurable (JSON content): created by
FeatureFlags.setConfig(_:config:). File exists = enabled, contents carry configuration. Hooks read config withfeature_config()(Python) orfeature_config(shell).
Hooks should always check file existence first (is the feature enabled?) and only read config when needed. An empty file means "enabled with defaults."
Pick a kebab-case name (e.g., my-feature). This is used in /tmp/navi/features/ and as the feature's identity across all layers.
In Sources/Navi/FloatingWindowManager.swift:
@Published var myFeatureEnabled: Bool {
didSet {
UserDefaults.standard.set(myFeatureEnabled, forKey: "NaviExp.MyFeature")
FeatureFlags.set("my-feature", enabled: myFeatureEnabled)
}
}In FloatingWindowManager.init(), add the default-on initialization block:
if UserDefaults.standard.object(forKey: "NaviExp.MyFeature") == nil {
UserDefaults.standard.set(true, forKey: "NaviExp.MyFeature")
myFeatureEnabled = true
} else {
myFeatureEnabled = UserDefaults.standard.bool(forKey: "NaviExp.MyFeature")
}In syncFeatureFlags(), add:
FeatureFlags.set("my-feature", enabled: myFeatureEnabled)In Sources/Navi/Views/ContentView.swift, add the toggle to experimentalTab (for new/unproven features) or generalTab (if it's ready to ship stable):
settingsRow("My Feature", subtitle: "Short description of what it does",
isOn: Binding(get: { floatingManager.myFeatureEnabled }, set: { floatingManager.myFeatureEnabled = $0 }))Gate feature-specific work behind the flag file:
In hook.sh:
if [ -f "$FEATURES_DIR/my-feature" ]; then
# feature-specific work
fiIn parse_event.py:
if feature_enabled("my-feature"):
# feature-specific workIn a new hook script:
[ -f /tmp/navi/features/my-feature ] || exit 0If the feature has settings beyond on/off (e.g., a timeout, a list of values):
Swift side — use FeatureFlags.setConfig instead of FeatureFlags.set in the didSet:
@Published var myTimeout: Double = 120 {
didSet {
UserDefaults.standard.set(myTimeout, forKey: "NaviExp.MyFeature.Timeout")
FeatureFlags.setConfig("my-feature", config: ["timeout": myTimeout])
}
}In parse_event.py:
config = feature_config("my-feature", default={})
timeout = config.get("timeout", 120)In hook.sh:
MY_TIMEOUT=$(feature_config my-feature timeout 120)An empty flag file means "enabled with defaults" — hooks should always fall back to sensible defaults when config is absent.
If the feature requires new hook types, add them to hooks/hooks.json. Note that hooks.json is loaded at Claude Code session start — users must restart sessions to pick up new hook registrations. The one-time version-upgrade banner (see below) covers this automatically on plugin updates.
- Default ON unless the feature is purely cosmetic and adds visible noise. Behavior-changing experimental features (auto-dismiss, instant notify, session status, permission details) default to ON for new installs (
UserDefaults.object == nil→set(true, ...)); the goal is for users to discover and try the feature without hunting in Settings. Purely cosmetic UI additions that layer extra visual elements onto existing UI (e.g. session-row badges) MAY default OFF — but the nil-check pattern still applies so the default is recorded explicitly and can be flipped in a future release without a migration. When choosing OFF, note the rationale in the toggle'sdidSetor near its initialization. The fourNaviExp.Show*toggles (folder/git/mode/model badges) are the canonical example. - Flag files are the source of truth for hooks — hooks never read UserDefaults
- Swift toggles take effect immediately where possible — the
didSetwrites the flag file and SwiftUI reactivity handles UI changes - Prefer runtime flag gating over removing hooks from
hooks.json— removing a hook registration requires users to restart Claude sessions to pick up the change, whereas a flag file check takes effect immediately. Only remove a hook registration when it is genuinely obsolete (e.g., its work is fully covered by another mechanism), not merely when it is temporarily unwanted. - If a feature requires init-time setup inside Navi (e.g.,
DispatchSource), passrequiresRestart: truetosettingsRow(). This setsfloatingManager.pendingRestart = trueon change and shows a restart banner with a "Restart Navi" button. - If a feature requires new hooks in
hooks.json(not just gating existing hooks), the version upgrade banner handles the restart hint automatically. On first launch after a plugin update,FloatingWindowManagercomparesNaviLastVersionwithnaviCurrentVersionand shows a one-time dismissable banner: "Navi updated — restart Claude sessions for new features".
When a feature is stable and ready to graduate from the Experimental tab:
Move the experimentalRow(...) call from experimentalTab to generalTab in ContentView. Change it to use the general settings UI pattern (it no longer needs the "experimental" framing).
To drop the NaviExp. prefix (e.g., NaviExp.MyFeature → Navi.MyFeature), add a one-time migration in init():
if UserDefaults.standard.object(forKey: "Navi.MyFeature") == nil,
let old = UserDefaults.standard.object(forKey: "NaviExp.MyFeature") as? Bool {
UserDefaults.standard.set(old, forKey: "Navi.MyFeature")
UserDefaults.standard.removeObject(forKey: "NaviExp.MyFeature")
}The flag file mechanism (/tmp/navi/features/my-feature) stays — it's not specific to experimental status. It's how hooks know whether the feature is enabled, regardless of which settings tab the toggle lives in.
Rename myFeatureEnabled to drop any "experimental" connotation if present. Update all references in syncFeatureFlags(), didSet, and the UI binding.
Navi's extension API lets you build plugins that surface cards in the floating window without touching this repository. The contract is simple: write a JSON file to /tmp/navi/events/ and Navi renders it.
info is the only external event type. It produces a non-interactive, sticky status card — no approve/deny buttons, no permission semantics. Cards stay visible until the user dismisses them (the X button on each row) or your plugin explicitly removes them by writing a resolve file.
See docs/EXTENSION_API.md for the full schema, atomicity rules, and security constraints.
A typical Claude Code hook-based plugin looks like this:
hooks/hooks.json — register your hook:
{
"hooks": {
"Stop": [{ "type": "command", "command": "bash /path/to/your-plugin/hook.sh" }]
}
}hook.sh — check Navi is present, write an info event:
#!/bin/bash
NAVI_EVENTS="/tmp/navi/events"
[ -d "$NAVI_EVENTS" ] || exit 0 # no-op if Navi isn't installed
ID="$(date +%s)-$(openssl rand -hex 16)"
BODY="$(compute_your_message)" # your logic here
printf '{"id":"%s","timestamp":%s,"type":"info","title":"My Plugin","body":"%s","description":"","session_id":"%s","session_name":"","pid":0,"cwd":"","tty":"","tool_use_id":"","expires":0}\n' \
"$ID" "$(date +%s)" "$BODY" "${CLAUDE_SESSION_ID:-}" \
> "$NAVI_EVENTS/.${ID}.tmp" \
&& mv "$NAVI_EVENTS/.${ID}.tmp" "$NAVI_EVENTS/${ID}.json"The [ -d "$NAVI_EVENTS" ] guard is the key compatibility pattern — your plugin silently does nothing when Navi isn't installed, so it's safe to ship in shared dotfiles or team configs.
- Atomic writes only. Always write to a
.tmpfile andmv/os.renameto the final.jsonname. Navi only reads.jsonfiles so it never sees a partial write. session_idbinds the card to a session. The card shows under that session's row. If the session has already ended, the card renders without a session header.bodyis trusted display text. Keep it short, controlled, and free of user-derived input. Put AI-generated or freeform text indescription— it renders with a distinct italic style to signal it's not authoritative.idnonce must be 128 bits of randomness.openssl rand -hex 16produces exactly that. Never reuse ids.- Cards are sticky. Unlike other event types,
infocards don't auto-expire — they stay until dismissed. Design your message to be worth the real estate.
EnrichmentService is the canonical internal producer. It polls session transcripts, detects when input tokens cross the configured warning/critical thresholds, and injects info events directly into EventMonitor (bypassing the file pipeline). When context drops back below 140K (e.g. after /compact), it resolves the stored event IDs so the cards disappear automatically.
This pattern — produce on crossing, resolve on recovery — is a good model for any plugin that tracks a recoverable condition.
- Open an issue first for larger changes so we can discuss scope before you invest time.
- Keep PRs focused — one feature or fix per PR.
- Test your change locally (build with
NAVI_BUILD_FROM_SOURCE=1 bash build.shto install your in-progress changes in place, runswift test, exercise the feature across at least one full Claude Code session). - Update
CONTRIBUTING.mdif you change architecture or add a pattern worth documenting.