# 0.2 — Pivot AccountPilot to a macOS .app (Implementation Plan)

> **For agentic workers:** This is a **phase-level brief**, not a TDD step-by-step plan. Each of the 6 phases below ships independently and will get its own detailed plan (`docs/plans/0.2-pN-<name>.md`) written via `superpowers:writing-plans` *as we approach that phase* — premature TDD plans for week-7 work would be stale by week-2. Use `superpowers:executing-plans` against the per-phase plans, not against this document.

**Goal:** Replace AccountPilot's brew-distributed CLI with a drag-to-Applications native macOS .app that ships the existing Python sync engine, signed FDA helper, and a CLI surface (inside the bundle) for agents.

**Architecture:** SwiftUI app shell + embedded `Python.framework` runs the existing accountpilot Python package as a sub-process / in-process interpreter. Sync daemons run as XPC services managed by the app, not as user-visible launchd jobs. The signed FDA helper lives at `Contents/Helpers/accountpilot-fda-helper` so macOS TCC keys grants by code requirement (TeamID + bundle ID) — survives every Sparkle update. The bundled CLI at `Contents/Resources/bin/accountpilot` is the agent interface; first-launch optionally symlinks it to `/usr/local/bin/`. **No MCP server** — agents discover the CLI via `accountpilot --help` and invoke `<cmd> --json` for structured output.

**Tech Stack:** Swift 5.9 / SwiftUI (macOS 13+) · Python 3.13 (embedded via `Python.framework`) · existing `accountpilot` Python package (kept as-is) · existing Swift FDA helper (kept, repackaged inside the .app) · `Sparkle 2.x` for auto-update · `create-dmg` for distribution · GitHub Releases + GH Pages (appcast hosting) · `notarytool` for Apple notarization

---

## Why this exists

AccountPilot 0.1.x ships as a Python package via Homebrew. The brew model has accumulated three structural problems consumer-grade Mac software does not have:

1. **TCC FDA grants don't survive `brew upgrade`.** Each version moves the helper into a fresh `Cellar/<version>/bin/` path; TCC keys grants by absolute path for non-bundled CLI binaries. Confirmed empirically in 0.1.4 verification — embedded Info.plist alone is insufficient. Only `.app` bundles get TCC code-requirement matching.
2. **`brew install` is a developer pattern, not a consumer pattern.** The product audience is macOS users who expect drag-to-Applications.
3. **Multi-Python environment confusion.** `service install` baked the wrong Python interpreter into launchd plists when users had anaconda/pyenv on `$PATH` (fixed in 0.1.4 via `sys.executable` resolution, but the underlying brittleness comes from depending on the host Python at all).

A self-contained `.app` with embedded `Python.framework` solves all three with the same architectural move.

## CLI as the agent interface (not MCP)

Per explicit decision: **agents interact with AccountPilot via the bundled CLI**. MCP was rejected — MCP servers inject tool schemas into every conversation context, which is expensive in tokens. The CLI:

- Lives at `AccountPilot.app/Contents/Resources/bin/accountpilot`
- First-launch wizard offers to symlink to `/usr/local/bin/accountpilot` (zero-friction agent discovery)
- Every read subcommand supports `--json` with stable schema
- Exit codes are part of the public contract (documented in `docs/cli-contract.md`)
- Agents discover surface via `accountpilot --help` once per session

This is a hard requirement that affects Phase 6 deliverables and partially Phase 1 (CLI must remain runnable from inside the .app from day one).

---

## Architecture

### Bundle layout

```
AccountPilot.app/
├── Contents/
│   ├── Info.plist                                 # CFBundleIdentifier=com.accountpilot.app
│   ├── MacOS/
│   │   └── AccountPilot                           # SwiftUI binary (entry point)
│   ├── Frameworks/
│   │   ├── Python.framework/                      # ≈40 MB, stripped Python 3.13
│   │   └── Sparkle.framework/                     # ≈3 MB
│   ├── Resources/
│   │   ├── python/                                # accountpilot venv (deps + accountpilot pkg)
│   │   ├── bin/
│   │   │   └── accountpilot                       # CLI shim (calls embedded Python)
│   │   └── Assets.car                             # icon, status-bar glyphs
│   ├── Helpers/
│   │   └── accountpilot-fda-helper                # signed Mach-O, embedded Info.plist
│   ├── XPCServices/
│   │   └── com.accountpilot.SyncService.xpc/      # background sync (per-account)
│   └── _CodeSignature/
```

**Why this matters for FDA:** the helper at `Contents/Helpers/` inherits the parent .app's TeamID + bundle ID context. macOS TCC matches grants against the .app's code requirement (`anchor apple generic and identifier "com.accountpilot.app" and certificate leaf[subject.OU] = "P2R7PD8VGY"`). The grant survives every Sparkle update because the TeamID and bundle ID are constant across versions.

### Process model

| Process | Role | Lifetime |
|---|---|---|
| `AccountPilot.app` | SwiftUI UI, status-bar icon, settings, data viewer | Foreground while user has window/menu open |
| `com.accountpilot.SyncService.xpc` | Mail / iMessage sync (one XPC instance per plugin) | Spawned by app, survives app quit if "background sync enabled" |
| `accountpilot-fda-helper` | Per-poll chat.db read | Short-lived, spawned by SyncService |
| `accountpilot` CLI | Agent invocations | Short-lived, spawned by user/agent |

The XPC service replaces the launchd plist model. macOS XPC handles supervision, restart-on-crash, and respects "background sync enabled" toggles in the app's Settings without touching launchd.

### Python embedding

`Python.framework` is the canonical Apple approach: a relocatable `.framework` directory containing the Python interpreter, stdlib, and any packages installed into a venv inside it. Built once per platform (arm64 and x86_64 universal binary), shipped inside the .app, completely independent of the host's Python.

The `accountpilot` package + transitive deps (`aioimaplib`, `pydantic`, `cryptography`, `google-auth`, `msal`, `pytypedstream`, `watchdog`, etc.) get installed into a venv at `Contents/Resources/python/` against the bundled Python.framework. Build script: `app/scripts/bundle-python.sh`.

The Swift app launches the CLI / sync service via standard `Process` API pointing at `Contents/Resources/python/bin/python` running `-m accountpilot.cli ...`.

### IPC between Swift UI and Python core

For Phase 1–3: spawn the Python CLI as a subprocess with `--json`, parse stdout. Simple, debuggable, mirrors how agents will invoke the CLI. Latency is fine for human UI interactions (~50ms cold-start once Python.framework is preloaded).

For Phase 3+ (background sync): same model but XPC-supervised, so crashes are isolated and SwiftUI gets clean status callbacks.

No long-lived embedded interpreter. Subprocess per command keeps the boundary clean, avoids GIL/thread headaches in Swift, and means the CLI behavior the agents see is *exactly* the behavior the UI sees.

### TCC FDA — the real fix

The .app bundle gets a CFBundleIdentifier of `com.accountpilot.app`. The helper at `Contents/Helpers/accountpilot-fda-helper` keeps `com.accountpilot.fda-helper` as its own bundle ID. Both signed under TeamID `P2R7PD8VGY`. TCC's grant for the .app key includes the helper as a "responsible child process" — code-sig matching applies, grant survives all updates.

User grants FDA exactly **once**, on first launch (guided by the FDA wizard in Phase 3). That grant is permanent for the bundle ID + TeamID.

---

## Phases

Each phase produces shippable software (per `superpowers:writing-plans` scope rule — every plan must produce working, testable software on its own). Phases run sequentially; Phase N depends on Phase N-1's deliverables existing in the codebase.

### Phase 1 — App skeleton + embedded Python (≈1 week)

**Goal:** Drag-to-Applications `AccountPilot.app` that opens, shows a one-window placeholder UI, and successfully invokes the embedded `accountpilot --version` from inside the bundle. CLI from `Contents/Resources/bin/accountpilot` works when invoked from a terminal pointing at the bundle.

**Deliverables:**
- `app/` Xcode project (Swift 5.9, macOS 13+ target)
- `app/AccountPilot/AccountPilotApp.swift` — `@main` SwiftUI entry
- `app/AccountPilot/PythonRuntime.swift` — wrapper around `Process` invocation of bundled Python
- `app/scripts/bundle-python.sh` — downloads Python.framework, creates venv, installs accountpilot from local source tree
- `app/scripts/build-app.sh` — full bundle assembly (Python.framework + helper + Swift binary + signing)
- `app/scripts/notarize-app.sh` — wraps `xcrun notarytool submit --wait`
- `Info.plist` with `CFBundleIdentifier=com.accountpilot.app`, `LSUIElement` (status-bar app), entitlements
- Helper moved into `Contents/Helpers/` (kept as-is from current 0.1.1 helper; only its install path changes)

**Verification:**
- `./app/scripts/build-app.sh` produces `dist/AccountPilot.app` that codesigns + notarizes cleanly
- Drag to `/Applications`, double-click, app opens
- App's startup screen shows "AccountPilot 0.2.0 — Python: 3.13.x — accountpilot: 0.1.4"
- Run `/Applications/AccountPilot.app/Contents/Resources/bin/accountpilot --version` from terminal — outputs `accountpilot, version 0.2.0`
- Run `/Applications/AccountPilot.app/Contents/Helpers/accountpilot-fda-helper --version` — outputs `accountpilot-fda-helper 0.1.1`
- `codesign -dv /Applications/AccountPilot.app` shows TeamID `P2R7PD8VGY` and bundle ID `com.accountpilot.app`

**Per-phase plan:** `docs/plans/0.2-p1-app-skeleton.md` (to be written when phase starts)

**Risks:**
- Python.framework relocation: the framework's Mach-O `LC_RPATH` and dyld load commands must be rewritten so the embedded Python finds its own stdlib at the bundle-relative path, not the build-time absolute path. Standard PyInstaller-style trick. Documented in `app/docs/python-embedding.md` to be written.
- Notarization of an .app bundle is more elaborate than for a single Mach-O — needs `--deep` signing, every Mach-O inside Frameworks/ + XPCServices/ + Helpers/ must be signed individually before the outer bundle.

### Phase 2 — Account management UI + config migration (≈1 week)

**Goal:** Replace `config.yaml` as the source of truth. SwiftUI list view shows all accounts, add/remove buttons run the existing OAuth flow against bundled publisher credentials, no terminal interaction needed.

**Deliverables:**
- `app/AccountPilot/Views/AccountsView.swift` — list, add, remove
- `app/AccountPilot/Views/AddAccountSheet.swift` — provider picker (Gmail, Outlook, iMessage), OAuth trigger, status feedback
- `app/AccountPilot/Models/Account.swift` — Codable mirror of accounts table
- `app/AccountPilot/Services/AccountStore.swift` — calls `accountpilot accounts list/add/remove --json`
- New CLI subcommands (Python side): `accountpilot accounts add --provider X --identifier Y --owner Z [--owner-name N --owner-surname S] --json` and `accountpilot accounts remove ID --json` (currently config.yaml-only — needs DB-write CLI surface)
- One-time migration: on first launch, if `~/Library/Application Support/accountpilot/config.yaml` exists, import it via `accountpilot setup` and then rename to `config.yaml.imported`

**Verification:**
- Fresh install (delete Application Support dir) → app launches → "Add account" → OAuth flow opens browser → consent → account appears in list with green status
- Remove account → row disappears; secrets file at `secrets/oauth/<provider>/<id>.json` deleted
- Existing 0.1.x users with `config.yaml` see all accounts auto-imported on first launch with no data loss
- `accountpilot accounts list --json` (CLI) returns the same account list the UI shows

**Per-phase plan:** `docs/plans/0.2-p2-accounts-ui.md`

### Phase 3 — FDA wizard + sync status + background daemon (≈3-5 days)

**Goal:** First-launch FDA grant is a guided wizard, not a documentation page. Sync runs in the background as an XPC service the app supervises. Status-bar icon shows last-sync time and any errors.

**Deliverables:**
- `app/AccountPilot/Views/Onboarding/FDAWizardView.swift` — detects helper EACCES, opens System Settings deep-link, polls helper every 1s, advances on first successful read
- `app/AccountPilotXPC/SyncService.swift` — XPC service that hosts the sync loop, calls `accountpilot mail sync` / `accountpilot imessage sync` per account on a configurable interval (default: continuous IDLE for mail, 10s poll for iMessage)
- `app/AccountPilot/Services/SyncSupervisor.swift` — Swift-side XPC client; "start sync" / "stop sync" / "sync now" / current-status callbacks
- `app/AccountPilot/Views/StatusBarView.swift` — menu-bar icon, last-sync timestamp per account, manual "Sync now" button
- Removal of `accountpilot service install` and the launchd plist templates — replaced by XPC

**Verification:**
- Fresh install on a Mac with no FDA grant → app launches → wizard appears with screenshot of what to click → user grants FDA → wizard auto-advances within 2s of grant taking effect
- Force-quit the app while sync running → XPC service survives, sync continues
- "Pause sync" toggle in Settings actually stops the XPC service (verifiable via `xpc status` / Activity Monitor)
- iMessage sync writes new rows to `imessage_details` within 30s of receiving a new message (existing chat.db watcher behavior preserved)
- Mail IDLE: send test email, row appears in `email_details` within 5s

**Per-phase plan:** `docs/plans/0.2-p3-fda-wizard-sync.md`

### Phase 4 — Data viewer (≈1-2 weeks)

**Goal:** Read-only browse of synced messages: per-account inbox, per-contact threads, full-text search powered by the existing `messages_fts` FTS5 table. Validates the data model and gives the user immediate visible value.

**Deliverables:**
- `app/AccountPilot/Views/Browse/MessagesView.swift` — three-pane layout: account/contact sidebar, message list, message detail
- `app/AccountPilot/Views/Browse/SearchView.swift` — query bar with FTS5 syntax support
- `app/AccountPilot/Models/Message.swift` — unified iMessage + email row model
- New CLI subcommands: `accountpilot messages list [--account ID] [--contact HANDLE] [--since YYYY-MM-DD] [--limit N] --json` and `accountpilot messages get <id> --json` and `accountpilot search "query" --json`
- Attachment preview: thumbnail in list, click to open in Quick Look (file already lives in CAS)

**Verification:**
- Click any account in sidebar → messages load within 200ms (typical: <5000 rows)
- Search "fazla" → returns all messages mentioning the term, ranked by FTS5 BM25
- Click a message with image attachment → thumbnail appears → cmd+space opens full-size in Quick Look
- All views work with `accountpilot search "x" --json` returning identical row set

**Per-phase plan:** `docs/plans/0.2-p4-data-viewer.md`

### Phase 5 — Sparkle auto-update + DMG distribution (≈2-3 days)

**Goal:** Every push of a `v*` tag produces a notarized DMG, attaches it to a GitHub Release, and updates the Sparkle appcast. Existing installs see the update prompt within ≤24h.

**Deliverables:**
- `app/AccountPilot/SparkleConfig.swift` — appcast URL, EdDSA public key
- Sparkle EdDSA private key (stored in keychain, public key bundled in the app)
- `.github/workflows/release-app.yml` — triggered by `v*` tags: builds .app, signs, notarizes, builds DMG via `create-dmg`, signs the DMG, generates appcast XML entry, commits to `gh-pages` branch
- `appcast.xml` hosted at `https://aren13.github.io/accountpilot/appcast.xml`
- `scripts/sign-update.sh` — generates EdDSA signature for the DMG, embedded in appcast entry

**Verification:**
- Tag `v0.2.0` → workflow runs → `dist/AccountPilot-0.2.0.dmg` lands on GitHub release
- Install 0.2.0 from DMG, then tag `v0.2.1` → installed app shows "Update available" within Sparkle's check interval
- Click Update → app restarts as 0.2.1 → FDA grant still works (the whole point — no re-grant needed because TeamID + bundle ID stable)

**Per-phase plan:** `docs/plans/0.2-p5-sparkle-dmg.md`

### Phase 6 — Agent-friendly CLI hardening (≈3-5 days)

**Goal:** Make the bundled CLI a first-class agent surface. Every read command supports `--json`. Stable exit codes. Predictable subcommand naming. Documented contract.

**Deliverables:**
- `--json` flag on every read subcommand: `accounts list`, `people list`, `messages list/get`, `search`, `status`, `oauth status`
- New machine-readable `status` subcommand: `accountpilot status --json` returns sync state, last-sync timestamps, error counts per account
- `accountpilot completion <shell>` — bash/zsh/fish completion (helps human power-users; agents discover via `--help`)
- First-launch CLI symlink prompt: `AccountPilot.app` on first run asks "Install command-line tools to /usr/local/bin/?" — runs `ln -sf <bundle>/Contents/Resources/bin/accountpilot /usr/local/bin/accountpilot`
- `docs/cli-contract.md` — public contract for agents: subcommand list, JSON schemas, exit codes, stability guarantees
- Stable exit codes documented:
  - `0` success
  - `2` usage error
  - `13` permission denied (FDA, OAuth)
  - `64` config error
  - `65` data error (corrupt DB, schema mismatch)
  - `69` external service unavailable (IMAP server down, OAuth token revoked)

**Verification:**
- Every command in `accountpilot --help` accepts `--json` (or is explicitly write-only)
- `accountpilot accounts list --json | jq '.[0].id'` returns an integer without errors
- `docs/cli-contract.md` schema matches actual output for every documented command (verified by `tests/cli_contract_test.py` — runs each command and validates JSON against published schema)
- Agent integration smoke test: write a 50-line shell script that uses only the CLI to add a Gmail account, trigger a sync, and search the resulting inbox. Script runs to completion.

**Per-phase plan:** `docs/plans/0.2-p6-cli-contract.md`

---

## Decisions log (locked in this brief)

| Decision | Rationale |
|---|---|
| Drop brew formula entirely after 0.2.0 ships | One canonical install. Brew's value (CLI distribution) is replaced by the bundled CLI + `/usr/local/bin/` symlink. |
| **No MCP server** | MCP injects schemas into every conversation context — expensive in tokens. Agents discover the CLI via `--help` once and invoke `<cmd> --json` as needed. Cheaper context, simpler integration. |
| Embed Python.framework, don't rewrite in Swift | Preserves the working sync engine, OAuth flow, plugin architecture. Rewriting is a 6-week tangent that delivers no user value. Python embedding is mature (used by Cinema 4D, Houdini, many Mac apps). |
| Subprocess per CLI invocation, not long-lived embedded interpreter | Same boundary the agents see. Crash isolation. No Swift/Python threading headaches. ~50ms latency is fine for UI. |
| XPC services for background sync, not launchd | Standard macOS pattern for app-managed background work. Survives app quit if user wants. No user-visible launchctl jobs to maintain. |
| Sparkle, not Mac App Store | MAS forbids the FDA-helper pattern (sandboxing). Sparkle is the macOS standard for non-MAS apps with auto-update. |
| Universal binary (arm64 + x86_64) from day one | Apple Silicon majority but Intel users still exist. CI builds both via macOS GH runners. |

## Risks

1. **Python.framework relocation is finicky.** Need to rewrite all `LC_RPATH` and embedded paths so dyld finds Python's own stdlib at bundle-relative paths. Mitigation: `python-build-standalone` (https://github.com/indygreg/python-build-standalone) ships pre-relocated builds — use those instead of building from source.
2. **Notarization rejection on first attempt.** Apple's notary checks every Mach-O in the bundle (Frameworks/Python.framework has dozens of `.so` files). Each must have hardened runtime + valid signature. Mitigation: Phase 1 includes a notarization smoke-test step before any UI work.
3. **TCC grant from existing 0.1.x users may not transfer.** Users on 0.1.x granted FDA to `/opt/homebrew/bin/accountpilot-fda-helper`. After installing the .app, that grant doesn't transfer to the new `Contents/Helpers/` path. Acceptable cost: one-time re-grant during 0.1.x → 0.2.0 migration. Documented in release notes; the FDA wizard handles it.
4. **brew users on 0.1.x might not see the deprecation notice.** Mitigation: 0.1.5 ships with a startup banner pointing at the .app download. 0.1.5 is the last brew release.
5. **Sparkle EdDSA private key loss = no more updates.** Mitigation: store key in 1Password vault; document rotation procedure in `docs/release-process.md`.

## Out of scope for 0.2

- Mac App Store distribution (MAS sandboxing forbids the FDA helper architecture)
- Sending mail / replying to messages (read-only product for 0.2)
- Calendar / Contacts / Photos plugins (separate plugin work, can ship after 0.2 in any plugin order)
- Multi-machine sync (deliberately single-machine per `CLAUDE.md` invariant)
- Web UI (would duplicate the SwiftUI surface)
- Linux build of the .app (the .app pivot is macOS-specific; Linux users continue using the Python package via pip)

## Dependencies between phases

```
P1 (skeleton) ──► P2 (accounts UI) ──► P3 (FDA wizard + sync XPC) ──► P5 (Sparkle + DMG)
                                  └──► P4 (data viewer) ──────────┘
                                                                   └──► P6 (CLI hardening)
```

P1 and P6 could in principle be parallelized, but P6's CLI hardening depends on knowing the final command surface, which firms up during P2–P4. Sequential is simpler.

## Next steps

1. **Review this brief.** If misaligned at the strategic level (phase order, scope, decision log), stop here and revise. Cheap to redirect now, expensive after Phase 1 ships.
2. **Write Phase 1's detailed plan** (`docs/plans/0.2-p1-app-skeleton.md`) via `superpowers:writing-plans` — TDD step-by-step granularity, will run ~150-300 lines.
3. **Execute Phase 1** via `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans`. Verifiable deliverable at end of week 1.
4. **At end of each phase**, write the next phase's plan and review it before executing. Keeps detail-level work fresh and lets reality from earlier phases inform later phases.
