# 0.2 — Phase 2: Account management UI + config migration (Implementation Plan)

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Replace `config.yaml` as the source of truth for accounts. SwiftUI list view shows all accounts in the bundled .app, "Add account" buttons launch the existing OAuth flow against bundled publisher credentials, "Remove" deletes secret + DB row. Fresh installs need zero terminal interaction. Existing 0.1.x installs auto-import their `config.yaml` on first launch.

**Architecture:** Swift talks to Python via `Process` invocations of `Contents/Resources/bin/accountpilot ... --json` and parses stdout. No long-lived embedded interpreter, no XPC yet (Phase 3). New CLI subcommands extend the existing `accounts_group` and `oauth_group`; a new `config import` command handles the one-time YAML→DB migration. The Swift side adds three layers: `Models/Account.swift` (Codable mirror of the `accounts` table), `Services/AccountStore.swift` (CLI wrapper, single source of truth for the UI), and two views (`AccountsView`, `AddAccountSheet`). First-launch migration runs in `AccountPilotApp.swift` before `ContentView` appears.

**Tech Stack:** Click + Pydantic (Python CLI) · pytest + pytest-asyncio (Python tests) · SwiftUI + Combine (Swift UI) · `Process` + `Pipe` (Swift→Python IPC) · existing OAuth flows (`google_interactive_login`, `microsoft_interactive_login`) · existing DB schema (no migrations)

---

## Prerequisites

Before starting Task 1, ensure:

- [ ] Phase 1 has merged to `main`. Verify: `git log --oneline main | head -3` shows the Phase 1 merge commit.
- [ ] You are on a Phase 2 branch: `git checkout -b 0.2-p2-accounts-ui` (already created if you started from this plan's first session).
- [ ] `pip install -e ".[dev]"` works; `pytest -q` is green from `main`.
- [ ] `dist/AccountPilot.app` from Phase 1 still builds (`./app/scripts/build-app.sh` ends with `==> all verification passed`). Phase 2's manual smoke test in Task 14 needs a fresh build of the Phase 2 .app.
- [ ] `xcrun notarytool history --keychain-profile accountpilot-notary` succeeds (the keychain credential profile from Phase 1 must still be present).

## File structure (created or modified by this phase)

```
src/accountpilot/core/cli/
├── accounts_cmds.py         # MODIFY: add --json to list/disable/delete; new add, remove, enable
├── oauth_cmd.py             # MODIFY: add --json to login google/microsoft
├── config_cmd.py            # CREATE: `accountpilot config import` (YAML → DB one-shot)
└── __init__.py              # (no change)

src/accountpilot/cli.py      # MODIFY: register config_group

tests/core/cli/
├── test_accounts_cmds_json.py   # CREATE: --json output schema for list/add/remove
├── test_config_import.py        # CREATE: YAML→DB import + rename behaviour
└── test_oauth_login_json.py     # CREATE: --json output of oauth login (mocked flow)

app/AccountPilot/
├── Models/
│   └── Account.swift                # CREATE: Codable struct mirroring `accountpilot accounts list --json`
├── Services/
│   ├── AccountStore.swift           # CREATE: ObservableObject wrapping CLI calls
│   └── ConfigMigration.swift        # CREATE: first-launch config.yaml import
├── Views/
│   ├── AccountsView.swift           # CREATE: list + remove + "Add account" trigger
│   └── AddAccountSheet.swift        # CREATE: provider picker + identifier entry + OAuth driver
├── ContentView.swift                # MODIFY: replace placeholder with AccountsView (keeps version footer)
└── AccountPilotApp.swift            # MODIFY: run ConfigMigration.runIfNeeded() before window shows

app/AccountPilotTests/
├── AccountStoreTests.swift          # CREATE: AccountStore CLI-output decoding (uses fixture, no real CLI)
└── ConfigMigrationTests.swift       # CREATE: first-launch idempotency

CHANGELOG.md                         # MODIFY: add Phase 2 entries under [0.2.0]
```

---

## Task 1: `accountpilot accounts list --json` — JSON output mode

**Goal:** Make the existing `list` command emit a stable JSON schema when `--json` is passed. This is the canonical contract the Swift `AccountStore` decodes; every other JSON command in this plan follows the same envelope.

**Files:**
- Modify: `src/accountpilot/core/cli/accounts_cmds.py`
- Test: `tests/core/cli/test_accounts_cmds_json.py` (new)

**JSON envelope used throughout this plan:**

```json
{
  "ok": true,
  "data": <command-specific payload>,
  "error": null
}
```

On error: `{"ok": false, "data": null, "error": {"code": "STRING", "message": "..."}}`. Keeping `data` and `error` always present (not omitted) makes the Swift Codable struct unambiguous.

- [ ] **Step 1: Write the failing test**

`tests/core/cli/test_accounts_cmds_json.py`:

```python
"""Tests for `accountpilot accounts ... --json`."""

from __future__ import annotations

import json
from pathlib import Path

import pytest
from click.testing import CliRunner

from accountpilot.core.cli.accounts_cmds import accounts_group
from accountpilot.core.db.connection import open_db
from accountpilot.core.db.migrations import apply_migrations


@pytest.fixture
async def populated_db(tmp_path: Path) -> Path:
    db = tmp_path / "test.db"
    async with open_db(db) as conn:
        await apply_migrations(conn)
        await conn.execute(
            "INSERT INTO people (id, name, surname, is_owner, created_at, updated_at) "
            "VALUES (1, 'Ada', 'Lovelace', 1, '2026-01-01T00:00:00+00:00', "
            "'2026-01-01T00:00:00+00:00')"
        )
        await conn.execute(
            "INSERT INTO accounts (id, owner_id, source, account_identifier, "
            "enabled, created_at, updated_at) "
            "VALUES (1, 1, 'gmail', 'ada@example.com', 1, "
            "'2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')"
        )
        await conn.commit()
    return db


def test_list_json_returns_envelope(populated_db: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        accounts_group,
        ["list", "--json", "--db-path", str(populated_db)],
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload == {
        "ok": True,
        "data": {
            "accounts": [
                {
                    "id": 1,
                    "source": "gmail",
                    "identifier": "ada@example.com",
                    "enabled": True,
                    "owner_id": 1,
                    "owner_name": "Ada Lovelace",
                }
            ]
        },
        "error": None,
    }


def test_list_json_empty_db(tmp_path: Path) -> None:
    """Empty accounts list still emits a valid envelope, not no output."""
    db = tmp_path / "empty.db"
    # Apply migrations so the table exists; no rows.
    import asyncio
    async def _setup() -> None:
        async with open_db(db) as conn:
            await apply_migrations(conn)
    asyncio.run(_setup())

    runner = CliRunner()
    result = runner.invoke(
        accounts_group, ["list", "--json", "--db-path", str(db)]
    )
    assert result.exit_code == 0
    payload = json.loads(result.output)
    assert payload == {"ok": True, "data": {"accounts": []}, "error": None}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pytest tests/core/cli/test_accounts_cmds_json.py -v`
Expected: FAIL — both tests, with the human-readable `#1 [on] gmail …` output instead of JSON.

- [ ] **Step 3: Add a `_json_envelope` helper + `--json` to `accounts list`**

In `src/accountpilot/core/cli/accounts_cmds.py`, add at module top under existing imports:

```python
import json
from typing import Any


def _emit_envelope(*, data: Any | None = None, error: dict[str, str] | None = None) -> None:
    """Emit the standard JSON envelope to stdout. One call per CLI invocation."""
    payload = {"ok": error is None, "data": data, "error": error}
    click.echo(json.dumps(payload))
```

Then modify `accounts_list`:

```python
@accounts_group.command("list")
@click.option("--json", "json_out", is_flag=True, help="Emit machine-readable JSON.")
@_db_option
def accounts_list(db_path: Path, json_out: bool) -> None:
    async def _run() -> None:
        async with (
            open_db(db_path) as db,
            db.execute(
                "SELECT a.id, a.source, a.account_identifier, a.enabled, "
                "a.owner_id, p.name || COALESCE(' ' || p.surname, '') AS owner_name "
                "FROM accounts a JOIN people p ON p.id=a.owner_id "
                "ORDER BY a.id"
            ) as cur,
        ):
            rows = await cur.fetchall()

        if json_out:
            _emit_envelope(
                data={
                    "accounts": [
                        {
                            "id": r["id"],
                            "source": r["source"],
                            "identifier": r["account_identifier"],
                            "enabled": bool(r["enabled"]),
                            "owner_id": r["owner_id"],
                            "owner_name": r["owner_name"],
                        }
                        for r in rows
                    ]
                }
            )
            return

        for r in rows:
            state = "[on]" if r["enabled"] else "[off]"
            click.echo(
                f"#{r['id']} {state} {r['source']:<10}  {r['account_identifier']:<30} "
                f"owner={r['owner_name']!r}"
            )

    asyncio.run(_run())
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/core/cli/test_accounts_cmds_json.py -v`
Expected: PASS — both tests green.

- [ ] **Step 5: Sanity-check the human-readable mode still works**

Run: `pytest tests/core/cli/ -k accounts -v` (any pre-existing accounts tests must still pass).
Expected: no regressions.

- [ ] **Step 6: Commit**

```bash
git add src/accountpilot/core/cli/accounts_cmds.py tests/core/cli/test_accounts_cmds_json.py
git commit -m "feat(cli): accounts list --json with stable envelope"
```

---

## Task 2: `accountpilot accounts add` — DB-write CLI surface

**Goal:** Add the missing `add` subcommand. Inserts an owner (find-or-create) + an `accounts` row. Emits the new account's id+identifier on success. The Swift app calls this *before* triggering OAuth (because oauth login takes an `account_id`).

**Files:**
- Modify: `src/accountpilot/core/cli/accounts_cmds.py`
- Modify: `tests/core/cli/test_accounts_cmds_json.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/core/cli/test_accounts_cmds_json.py`:

```python
def test_add_creates_account_with_new_owner(tmp_path: Path) -> None:
    """Add an account with --owner-name; the owner row is created."""
    db = tmp_path / "test.db"
    import asyncio
    async def _setup() -> None:
        async with open_db(db) as conn:
            await apply_migrations(conn)
    asyncio.run(_setup())

    runner = CliRunner()
    result = runner.invoke(
        accounts_group,
        [
            "add", "--json",
            "--provider", "gmail",
            "--identifier", "ada@example.com",
            "--owner-name", "Ada",
            "--owner-surname", "Lovelace",
            "--db-path", str(db),
        ],
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["ok"] is True
    assert payload["data"]["account"]["id"] == 1
    assert payload["data"]["account"]["source"] == "gmail"
    assert payload["data"]["account"]["identifier"] == "ada@example.com"
    assert payload["data"]["account"]["owner_id"] == 1
    assert payload["error"] is None


def test_add_reuses_existing_owner_by_identifier(tmp_path: Path) -> None:
    """Adding a second gmail account for the same owner reuses person row 1."""
    db = tmp_path / "test.db"
    import asyncio
    async def _setup() -> None:
        async with open_db(db) as conn:
            await apply_migrations(conn)
    asyncio.run(_setup())

    runner = CliRunner()
    runner.invoke(
        accounts_group,
        [
            "add", "--json",
            "--provider", "gmail", "--identifier", "ada@example.com",
            "--owner-name", "Ada", "--owner-surname", "Lovelace",
            "--db-path", str(db),
        ],
    )
    result2 = runner.invoke(
        accounts_group,
        [
            "add", "--json",
            "--provider", "gmail", "--identifier", "ada+work@example.com",
            "--owner-name", "Ada", "--owner-surname", "Lovelace",
            "--db-path", str(db),
        ],
    )
    assert result2.exit_code == 0, result2.output
    payload = json.loads(result2.output)
    assert payload["data"]["account"]["owner_id"] == 1, "should reuse owner #1"
    assert payload["data"]["account"]["id"] == 2


def test_add_duplicate_identifier_returns_error(tmp_path: Path) -> None:
    """Same (source, identifier) twice is a unique-constraint violation —
    surface as a clean error, not a stack trace."""
    db = tmp_path / "test.db"
    import asyncio
    async def _setup() -> None:
        async with open_db(db) as conn:
            await apply_migrations(conn)
    asyncio.run(_setup())

    runner = CliRunner()
    runner.invoke(
        accounts_group,
        [
            "add", "--json",
            "--provider", "gmail", "--identifier", "ada@example.com",
            "--owner-name", "Ada", "--db-path", str(db),
        ],
    )
    result2 = runner.invoke(
        accounts_group,
        [
            "add", "--json",
            "--provider", "gmail", "--identifier", "ada@example.com",
            "--owner-name", "Ada", "--db-path", str(db),
        ],
    )
    # Exit code is 0 because we surface the error in the JSON envelope, not via
    # exit code — the Swift caller parses stdout regardless of exit code.
    assert result2.exit_code == 0, result2.output
    payload = json.loads(result2.output)
    assert payload["ok"] is False
    assert payload["error"]["code"] == "ACCOUNT_EXISTS"
    assert "ada@example.com" in payload["error"]["message"]
```

- [ ] **Step 2: Run test to verify they fail**

Run: `pytest tests/core/cli/test_accounts_cmds_json.py::test_add_creates_account_with_new_owner -v`
Expected: FAIL — `add` is not a known command.

- [ ] **Step 3: Implement `accounts add`**

In `src/accountpilot/core/cli/accounts_cmds.py`, add new imports near the existing ones:

```python
from accountpilot.core.cas import CASStore
from accountpilot.core.models import Identifier, IdentifierKind
from accountpilot.core.storage import Storage
```

Then add the new command at the end of the file:

```python
def _identifier_kind_for_provider(provider: str) -> IdentifierKind:
    """Map provider → the identifier kind we record on the owner row."""
    if provider in {"gmail", "outlook", "imap-generic"}:
        return "email"
    if provider == "imessage":
        # Best-guess; user can have phone or email handles. The UI passes
        # whichever string the user typed; the kind is informational only
        # for surfacing in `accountpilot people list`.
        return "email"
    raise click.UsageError(f"unknown provider: {provider}")


@accounts_group.command("add")
@click.option("--json", "json_out", is_flag=True)
@click.option(
    "--provider",
    type=click.Choice(["gmail", "outlook", "imap-generic", "imessage"]),
    required=True,
)
@click.option("--identifier", required=True, help="Account identifier (email, phone, etc.)")
@click.option("--owner-name", required=True)
@click.option("--owner-surname", default=None)
@click.option(
    "--credentials-ref",
    default=None,
    help="Optional pointer into secrets store (e.g. oauth/google/<id>.json).",
)
@_db_option
def accounts_add(
    json_out: bool,
    provider: str,
    identifier: str,
    owner_name: str,
    owner_surname: str | None,
    credentials_ref: str | None,
    db_path: Path,
) -> None:
    """Create an account row + its owner (find-or-create by identifier)."""

    async def _run() -> dict[str, Any]:
        cas_root = db_path.parent / "attachments"
        async with open_db(db_path) as db:
            storage = Storage(db, CASStore(cas_root))
            kind = _identifier_kind_for_provider(provider)
            owner_id = await storage.upsert_owner(
                name=owner_name,
                surname=owner_surname,
                identifiers=[Identifier(kind=kind, value=identifier)],
            )
            try:
                account_id = await storage.upsert_account(
                    source=provider,
                    identifier=identifier,
                    owner_id=owner_id,
                    credentials_ref=credentials_ref,
                )
            except Exception as exc:  # IntegrityError is the common case
                msg = str(exc).lower()
                if "unique" in msg or "duplicate" in msg:
                    return {
                        "ok": False,
                        "error": {
                            "code": "ACCOUNT_EXISTS",
                            "message": (
                                f"account already exists: provider={provider} "
                                f"identifier={identifier!r}"
                            ),
                        },
                    }
                raise
            return {
                "ok": True,
                "account": {
                    "id": account_id,
                    "source": provider,
                    "identifier": identifier,
                    "owner_id": owner_id,
                },
            }

    result = asyncio.run(_run())
    if json_out:
        if result["ok"]:
            _emit_envelope(data={"account": result["account"]})
        else:
            _emit_envelope(error=result["error"])
        return
    if not result["ok"]:
        raise click.ClickException(result["error"]["message"])
    click.echo(
        f"added account #{result['account']['id']}: "
        f"{result['account']['source']}/{result['account']['identifier']}"
    )
```

You'll also need `Storage.upsert_account` to **return the row's id** if it doesn't already. Check `src/accountpilot/core/storage.py:366` — it currently returns `None` or the new id. If it returns `None`, modify the function:

```python
async def upsert_account(
    self,
    *,
    source: str,
    identifier: str,
    owner_id: int,
    credentials_ref: str | None = None,
) -> int:
    # ... existing body ...
    # At the end, return the row id (look it up if INSERT OR IGNORE was used):
    async with self.db.execute(
        "SELECT id FROM accounts WHERE source=? AND account_identifier=?",
        (source, identifier),
    ) as cur:
        row = await cur.fetchone()
    return int(row["id"])
```

(Read `src/accountpilot/core/storage.py:366-410` first to confirm exact current shape; only change the return path, not the semantics.)

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/core/cli/test_accounts_cmds_json.py -v`
Expected: all 5 tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/accountpilot/core/cli/accounts_cmds.py tests/core/cli/test_accounts_cmds_json.py src/accountpilot/core/storage.py
git commit -m "feat(cli): accounts add --json with find-or-create owner"
```

---

## Task 3: `accountpilot accounts remove` — symmetric delete

**Goal:** A name aligned with the UI verb ("Remove account"). Deletes the account row + cascades messages + sync_status (existing `delete` does the same; `remove` is a thin alias that always implies `--force` and adds `--json`).

**Files:**
- Modify: `src/accountpilot/core/cli/accounts_cmds.py`
- Modify: `tests/core/cli/test_accounts_cmds_json.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/core/cli/test_accounts_cmds_json.py`:

```python
def test_remove_deletes_account(populated_db: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        accounts_group, ["remove", "1", "--json", "--db-path", str(populated_db)]
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload == {
        "ok": True,
        "data": {"removed_id": 1},
        "error": None,
    }

    list_result = runner.invoke(
        accounts_group, ["list", "--json", "--db-path", str(populated_db)]
    )
    assert json.loads(list_result.output)["data"]["accounts"] == []


def test_remove_unknown_id_returns_error(populated_db: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        accounts_group, ["remove", "999", "--json", "--db-path", str(populated_db)]
    )
    assert result.exit_code == 0
    payload = json.loads(result.output)
    assert payload["ok"] is False
    assert payload["error"]["code"] == "ACCOUNT_NOT_FOUND"
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/core/cli/test_accounts_cmds_json.py::test_remove_deletes_account -v`
Expected: FAIL — `remove` not a known command.

- [ ] **Step 3: Implement `accounts remove`**

Append to `src/accountpilot/core/cli/accounts_cmds.py`:

```python
@accounts_group.command("remove")
@click.argument("account_id", type=int)
@click.option("--json", "json_out", is_flag=True)
@_db_option
def accounts_remove(account_id: int, json_out: bool, db_path: Path) -> None:
    """Delete an account + its messages + sync_status (no confirmation)."""

    async def _run() -> dict[str, Any]:
        async with open_db(db_path) as db:
            async with db.execute(
                "SELECT id FROM accounts WHERE id=?", (account_id,)
            ) as cur:
                row = await cur.fetchone()
            if row is None:
                return {
                    "ok": False,
                    "error": {
                        "code": "ACCOUNT_NOT_FOUND",
                        "message": f"no account with id={account_id}",
                    },
                }
            await db.execute("DELETE FROM messages WHERE account_id=?", (account_id,))
            await db.execute(
                "DELETE FROM sync_status WHERE account_id=?", (account_id,)
            )
            await db.execute("DELETE FROM accounts WHERE id=?", (account_id,))
            await db.commit()
        return {"ok": True}

    result = asyncio.run(_run())
    if json_out:
        if result["ok"]:
            _emit_envelope(data={"removed_id": account_id})
        else:
            _emit_envelope(error=result["error"])
        return
    if not result["ok"]:
        raise click.ClickException(result["error"]["message"])
    click.echo(f"removed account #{account_id}")
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/core/cli/test_accounts_cmds_json.py -v`
Expected: all 7 tests green.

- [ ] **Step 5: Commit**

```bash
git add src/accountpilot/core/cli/accounts_cmds.py tests/core/cli/test_accounts_cmds_json.py
git commit -m "feat(cli): accounts remove --json (no-confirm symmetric delete)"
```

---

## Task 4: `accountpilot config import` — one-shot YAML migration

**Goal:** A standalone command the Swift app calls on first launch when it detects `~/Library/Application Support/accountpilot/config.yaml`. Reads the YAML, applies it through the same path `setup_cmd` uses, then renames the YAML to `config.yaml.imported` so the migration only runs once. JSON-only output (the Swift caller is the only consumer).

**Files:**
- Create: `src/accountpilot/core/cli/config_cmd.py`
- Create: `tests/core/cli/test_config_import.py`
- Modify: `src/accountpilot/cli.py` (register the new group)

- [ ] **Step 1: Write the failing test**

`tests/core/cli/test_config_import.py`:

```python
"""Tests for `accountpilot config import`."""

from __future__ import annotations

import asyncio
import json
from pathlib import Path
from textwrap import dedent

from click.testing import CliRunner

from accountpilot.core.cli.config_cmd import config_group
from accountpilot.core.db.connection import open_db
from accountpilot.core.db.migrations import apply_migrations


def _seed_db(db: Path) -> None:
    async def _run() -> None:
        async with open_db(db) as conn:
            await apply_migrations(conn)
    asyncio.run(_run())


def _write_minimal_config(path: Path) -> None:
    path.write_text(dedent(
        """\
        version: 1
        owners:
          - name: Ada
            surname: Lovelace
            identifiers:
              - kind: email
                value: ada@example.com
        plugins:
          mail:
            enabled: true
            accounts:
              - identifier: ada@example.com
                owner: ada@example.com
                provider: gmail
        """
    ))


def test_import_applies_yaml_then_renames_it(tmp_path: Path) -> None:
    db = tmp_path / "test.db"
    config = tmp_path / "config.yaml"
    _seed_db(db)
    _write_minimal_config(config)

    runner = CliRunner()
    result = runner.invoke(
        config_group,
        ["import", "--config", str(config), "--db-path", str(db), "--json"],
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["ok"] is True
    assert payload["data"]["accounts_imported"] == 1
    assert payload["data"]["renamed_to"].endswith("config.yaml.imported")

    # YAML moved out of the way
    assert not config.exists()
    assert (tmp_path / "config.yaml.imported").exists()

    # DB has the row
    async def _check() -> int:
        async with open_db(db) as conn, conn.execute(
            "SELECT COUNT(*) AS n FROM accounts"
        ) as cur:
            row = await cur.fetchone()
        return int(row["n"])
    assert asyncio.run(_check()) == 1


def test_import_missing_yaml_is_noop_success(tmp_path: Path) -> None:
    """If config.yaml is absent, return a "nothing to do" success (the Swift
    caller invokes this unconditionally on first launch)."""
    db = tmp_path / "test.db"
    _seed_db(db)
    runner = CliRunner()
    result = runner.invoke(
        config_group,
        [
            "import", "--json",
            "--config", str(tmp_path / "config.yaml"),  # does not exist
            "--db-path", str(db),
        ],
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload == {
        "ok": True,
        "data": {"accounts_imported": 0, "renamed_to": None, "noop": True},
        "error": None,
    }


def test_import_already_imported_is_noop(tmp_path: Path) -> None:
    """Re-running import after a successful import should do nothing."""
    db = tmp_path / "test.db"
    config = tmp_path / "config.yaml"
    _seed_db(db)
    _write_minimal_config(config)

    runner = CliRunner()
    runner.invoke(
        config_group,
        ["import", "--config", str(config), "--db-path", str(db), "--json"],
    )
    # Second invocation: config.yaml is gone (renamed); should noop.
    result2 = runner.invoke(
        config_group,
        ["import", "--config", str(config), "--db-path", str(db), "--json"],
    )
    payload = json.loads(result2.output)
    assert payload["data"]["noop"] is True
    assert payload["data"]["accounts_imported"] == 0
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/core/cli/test_config_import.py -v`
Expected: FAIL — `config_cmd` module does not exist.

- [ ] **Step 3: Implement `config import`**

Create `src/accountpilot/core/cli/config_cmd.py`:

```python
# AccountPilot — unified per-machine account sync framework
# Copyright (C) 2026 Hasan Arda Eren <ardaeren13@gmail.com>
# Licensed under AGPL-3.0-or-later. See COPYING.

"""accountpilot config — one-shot YAML→DB migration."""

from __future__ import annotations

import asyncio
import json
from pathlib import Path

import click

from accountpilot.core import paths
from accountpilot.core.cas import CASStore
from accountpilot.core.config import load_config
from accountpilot.core.db.connection import open_db
from accountpilot.core.models import Identifier
from accountpilot.core.storage import Storage


def _emit_envelope(*, data: object | None = None, error: dict[str, str] | None = None) -> None:
    payload = {"ok": error is None, "data": data, "error": error}
    click.echo(json.dumps(payload))


@click.group("config")
def config_group() -> None:
    """Configuration migration commands."""


@config_group.command("import")
@click.option(
    "--config",
    "config_path",
    type=click.Path(path_type=Path),
    default=lambda: paths.config_dir() / "config.yaml",
    show_default="$ACCOUNTPILOT_CONFIG_DIR/config.yaml",
)
@click.option(
    "--db-path",
    type=click.Path(path_type=Path),
    default=lambda: paths.data_dir() / "accountpilot.db",
    show_default="$ACCOUNTPILOT_DATA_DIR/accountpilot.db",
)
@click.option("--json", "json_out", is_flag=True)
def config_import(config_path: Path, db_path: Path, json_out: bool) -> None:
    """Import accounts from config.yaml into the DB, then rename it.

    The YAML is renamed to ``config.yaml.imported`` to make this a
    one-shot operation: subsequent calls become no-ops, so the Swift
    app can invoke it unconditionally on every launch.
    """
    if not config_path.exists():
        if json_out:
            _emit_envelope(data={"accounts_imported": 0, "renamed_to": None, "noop": True})
            return
        click.echo(f"no config at {config_path}; nothing to import")
        return

    cas_root = db_path.parent / "attachments"
    cfg = load_config(config_path)

    async def _run() -> int:
        async with open_db(db_path) as db:
            storage = Storage(db, CASStore(cas_root))
            owner_id_by_identifier: dict[str, int] = {}
            for owner in cfg.owners:
                pid = await storage.upsert_owner(
                    name=owner.name,
                    surname=owner.surname,
                    identifiers=[
                        Identifier(kind=i.kind, value=i.value)
                        for i in owner.identifiers
                    ],
                )
                for i in owner.identifiers:
                    owner_id_by_identifier[i.value.lower()] = pid

            count = 0
            for plugin_name, pcfg in cfg.plugins.items():
                if not pcfg.enabled:
                    continue
                for account in pcfg.accounts:
                    owner_pid = owner_id_by_identifier.get(account.owner.lower())
                    if owner_pid is None:
                        raise click.ClickException(
                            f"plugin {plugin_name!r} account "
                            f"{account.identifier!r} references unknown owner "
                            f"{account.owner!r}"
                        )
                    source = account.provider or plugin_name
                    await storage.upsert_account(
                        source=source,
                        identifier=account.identifier,
                        owner_id=owner_pid,
                        credentials_ref=account.credentials_ref,
                    )
                    count += 1
            return count

    accounts_imported = asyncio.run(_run())
    renamed = config_path.with_suffix(config_path.suffix + ".imported")
    config_path.rename(renamed)

    if json_out:
        _emit_envelope(
            data={
                "accounts_imported": accounts_imported,
                "renamed_to": str(renamed),
                "noop": False,
            }
        )
        return
    click.echo(
        f"imported {accounts_imported} account(s); renamed {config_path.name} → {renamed.name}"
    )
```

Now register the group. In `src/accountpilot/cli.py`, find the imports block (around line 30–40) and add:

```python
from accountpilot.core.cli.config_cmd import config_group
```

Then in the `cli.add_command(...)` block (around line 49–56), add a line:

```python
cli.add_command(config_group)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/core/cli/test_config_import.py -v`
Expected: all 3 tests green.

- [ ] **Step 5: Sanity-check the CLI registration**

Run: `python3 -m accountpilot.cli config --help`
Expected output should include:
```
Commands:
  import  Import accounts from config.yaml...
```

- [ ] **Step 6: Commit**

```bash
git add src/accountpilot/core/cli/config_cmd.py tests/core/cli/test_config_import.py src/accountpilot/cli.py
git commit -m "feat(cli): config import — YAML→DB one-shot migration"
```

---

## Task 5: `accountpilot oauth login {google,microsoft}` — `--json`

**Goal:** Add `--json` to the existing OAuth login subcommands. The Swift app calls these *after* `accounts add` to grab the refresh token. JSON output reports the secrets file path and (on failure) a structured error.

**Files:**
- Modify: `src/accountpilot/core/cli/oauth_cmd.py`
- Create: `tests/core/cli/test_oauth_login_json.py`

- [ ] **Step 1: Write the failing test (mocking the actual flow)**

`tests/core/cli/test_oauth_login_json.py`:

```python
"""Tests for `accountpilot oauth login {google,microsoft} --json`.

The actual OAuth flow opens a browser and runs a local server; we mock
``oauth_flow.google_interactive_login`` so the test is hermetic.
"""

from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import patch

from click.testing import CliRunner

from accountpilot.core.cli.oauth_cmd import oauth_group


def test_login_google_json_emits_envelope(tmp_path: Path) -> None:
    secrets_root = tmp_path / "secrets"
    config_dir = tmp_path / "config"

    fake_payload = {
        "client_id": "x", "client_secret": "y",
        "refresh_token": "rt", "token_uri": "https://oauth2.googleapis.com/token",
    }

    with patch(
        "accountpilot.core.cli.oauth_cmd.oauth_flow.google_interactive_login",
        return_value=fake_payload,
    ), patch(
        "accountpilot.core.cli.oauth_cmd._load_client_config",
        return_value={"client_id": "x", "client_secret": "y"},
    ):
        runner = CliRunner()
        result = runner.invoke(
            oauth_group,
            [
                "login", "google", "42", "--json",
                "--config-dir", str(config_dir),
                "--secrets-root", str(secrets_root),
            ],
        )

    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["ok"] is True
    assert payload["data"]["account_id"] == 42
    assert payload["data"]["provider"] == "google"
    assert payload["data"]["secret_path"].endswith("secrets/oauth/google/42.json")

    # File actually written
    secret_file = secrets_root / "oauth" / "google" / "42.json"
    assert secret_file.exists()
    assert json.loads(secret_file.read_text())["refresh_token"] == "rt"


def test_login_microsoft_json(tmp_path: Path) -> None:
    secrets_root = tmp_path / "secrets"
    config_dir = tmp_path / "config"

    fake_payload = {
        "access_token": "at", "refresh_token": "rt",
        "client_id": "abc", "authority": "https://login.microsoftonline.com/common",
    }

    with patch(
        "accountpilot.core.cli.oauth_cmd.oauth_flow.microsoft_interactive_login",
        return_value=fake_payload,
    ), patch(
        "accountpilot.core.cli.oauth_cmd._load_client_config",
        return_value={"client_id": "abc", "authority": "https://login.microsoftonline.com/common"},
    ):
        runner = CliRunner()
        result = runner.invoke(
            oauth_group,
            [
                "login", "microsoft", "7", "--json",
                "--config-dir", str(config_dir),
                "--secrets-root", str(secrets_root),
            ],
        )

    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["data"]["account_id"] == 7
    assert payload["data"]["provider"] == "microsoft"
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/core/cli/test_oauth_login_json.py -v`
Expected: FAIL — `--json` flag does not exist on the login subcommands.

- [ ] **Step 3: Add `--json` to both login subcommands**

In `src/accountpilot/core/cli/oauth_cmd.py`, add the same `_emit_envelope` helper near the top (after the existing `import json`):

```python
def _emit_envelope(*, data: dict[str, Any] | None = None, error: dict[str, str] | None = None) -> None:
    payload = {"ok": error is None, "data": data, "error": error}
    click.echo(json.dumps(payload))
```

Modify `login_google` to add `--json`:

```python
@login_group.command("google")
@click.argument("account_id", type=int)
@click.option(
    "--config-dir",
    type=click.Path(path_type=Path),
    default=lambda: paths.config_dir(),
    show_default="$ACCOUNTPILOT_CONFIG_DIR",
)
@click.option(
    "--secrets-root",
    type=click.Path(path_type=Path),
    default=lambda: paths.data_dir() / "secrets",
    show_default="$ACCOUNTPILOT_DATA_DIR/secrets",
)
@click.option("--json", "json_out", is_flag=True)
def login_google(
    account_id: int,
    config_dir: Path,
    secrets_root: Path,
    json_out: bool,
) -> None:
    """Run Google OAuth Desktop flow and persist refresh token."""
    try:
        client_config = _load_client_config("google", config_dir)
        payload = oauth_flow.google_interactive_login(
            client_config, scopes=_GOOGLE_SCOPES,
        )
    except Exception as exc:
        if json_out:
            _emit_envelope(error={"code": "OAUTH_FAILED", "message": str(exc)})
            return
        raise

    out = secrets_root / "oauth" / "google" / f"{account_id}.json"
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(json.dumps(payload, indent=2))
    out.chmod(0o600)
    if json_out:
        _emit_envelope(data={
            "account_id": account_id,
            "provider": "google",
            "secret_path": str(out),
        })
        return
    click.echo(f"oauth login: account={account_id} provider=google → {out}")
```

Then modify `login_microsoft` symmetrically:

```python
@login_group.command("microsoft")
@click.argument("account_id", type=int)
@click.option(
    "--config-dir",
    type=click.Path(path_type=Path),
    default=lambda: paths.config_dir(),
    show_default="$ACCOUNTPILOT_CONFIG_DIR",
)
@click.option(
    "--secrets-root",
    type=click.Path(path_type=Path),
    default=lambda: paths.data_dir() / "secrets",
    show_default="$ACCOUNTPILOT_DATA_DIR/secrets",
)
@click.option("--json", "json_out", is_flag=True)
def login_microsoft(
    account_id: int,
    config_dir: Path,
    secrets_root: Path,
    json_out: bool,
) -> None:
    """Run Microsoft msal interactive flow and persist refresh token."""
    try:
        client_config = _load_client_config("microsoft", config_dir)
        payload = oauth_flow.microsoft_interactive_login(
            client_id=client_config["client_id"],
            authority=client_config["authority"],
            scopes=_MICROSOFT_SCOPES,
        )
    except Exception as exc:
        if json_out:
            _emit_envelope(error={"code": "OAUTH_FAILED", "message": str(exc)})
            return
        raise

    out = secrets_root / "oauth" / "microsoft" / f"{account_id}.json"
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(json.dumps(payload, indent=2))
    out.chmod(0o600)
    if json_out:
        _emit_envelope(data={
            "account_id": account_id,
            "provider": "microsoft",
            "secret_path": str(out),
        })
        return
    click.echo(f"oauth login: account={account_id} provider=microsoft → {out}")
```

(The default lambdas for path options match what `config_cmd.py` does — paths are resolved at invocation time, not import time. This is a small departure from the existing `login_google`/`login_microsoft` which use `default=paths.config_dir` directly; preserve the original style if the existing tests rely on it.)

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/core/cli/test_oauth_login_json.py -v`
Expected: both tests green.

- [ ] **Step 5: Sanity-check no regressions**

Run: `pytest tests/core/cli/ -v`
Expected: all CLI tests green.

- [ ] **Step 6: Commit**

```bash
git add src/accountpilot/core/cli/oauth_cmd.py tests/core/cli/test_oauth_login_json.py
git commit -m "feat(cli): oauth login {google,microsoft} --json"
```

---

## Task 6: Swift `Account.swift` — Codable mirror

**Goal:** A struct that decodes one item out of `accountpilot accounts list --json`'s `data.accounts` array. Single source of type truth for the UI.

**Files:**
- Create: `app/AccountPilot/Models/Account.swift`

- [ ] **Step 1: Create the directory + write the model**

```bash
mkdir -p app/AccountPilot/Models
```

`app/AccountPilot/Models/Account.swift`:

```swift
import Foundation

/// Mirrors one row of `accountpilot accounts list --json`'s `data.accounts[]`.
/// Identifiable via the integer DB primary key.
struct Account: Codable, Identifiable, Hashable {
    let id: Int
    let source: String        // "gmail", "outlook", "imap-generic", "imessage"
    let identifier: String    // user-visible (email, phone, …)
    let enabled: Bool
    let ownerID: Int
    let ownerName: String

    enum CodingKeys: String, CodingKey {
        case id, source, identifier, enabled
        case ownerID = "owner_id"
        case ownerName = "owner_name"
    }
}

/// Top-level envelope shared by every `--json` CLI command. `T` is the
/// command-specific shape of `data`. Use `EmptyData` if the command
/// returns no payload (Swift requires the type parameter even for `null`).
struct CLIEnvelope<T: Decodable>: Decodable {
    let ok: Bool
    let data: T?
    let error: CLIError?

    struct CLIError: Decodable {
        let code: String
        let message: String
    }
}

struct EmptyData: Decodable {}

/// Convenience for `accounts list --json` — decodes
/// `{"ok": true, "data": {"accounts": [...]}, "error": null}` directly.
struct AccountsListData: Decodable {
    let accounts: [Account]
}
```

- [ ] **Step 2: Add to project + build**

Run: `cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -5 && cd ..`
Expected: `** BUILD SUCCEEDED **`. (Account.swift compiles cleanly; nothing else uses it yet.)

- [ ] **Step 3: Commit**

```bash
git add app/AccountPilot/Models/
git commit -m "feat(app): Account + CLIEnvelope Codable models"
```

---

## Task 7: Swift `AccountStore.swift` — CLI wrapper service

**Goal:** ObservableObject that's the single source of truth for the UI. Calls `Contents/Resources/bin/accountpilot accounts ... --json`, parses the envelope, exposes `@Published var accounts: [Account]`. All CLI invocation goes through `PythonRuntime.shared` (which we built in Phase 1).

**Files:**
- Create: `app/AccountPilot/Services/AccountStore.swift`
- Create: `app/AccountPilotTests/AccountStoreTests.swift`

- [ ] **Step 1: Write the failing test**

```bash
mkdir -p app/AccountPilot/Services
```

`app/AccountPilotTests/AccountStoreTests.swift`:

```swift
import XCTest
@testable import AccountPilot

final class AccountStoreTests: XCTestCase {

    /// Decoding an `accounts list --json` payload.
    func test_decodeAccountsListEnvelope() throws {
        let json = #"""
        {
          "ok": true,
          "data": {
            "accounts": [
              {"id": 1, "source": "gmail", "identifier": "ada@example.com",
               "enabled": true, "owner_id": 1, "owner_name": "Ada Lovelace"}
            ]
          },
          "error": null
        }
        """#
        let env = try JSONDecoder().decode(
            CLIEnvelope<AccountsListData>.self,
            from: Data(json.utf8)
        )
        XCTAssertTrue(env.ok)
        XCTAssertEqual(env.data?.accounts.count, 1)
        XCTAssertEqual(env.data?.accounts.first?.id, 1)
        XCTAssertEqual(env.data?.accounts.first?.source, "gmail")
        XCTAssertEqual(env.data?.accounts.first?.identifier, "ada@example.com")
        XCTAssertTrue(env.data?.accounts.first?.enabled ?? false)
    }

    /// Decoding an error envelope: `data` is null, `error` populated.
    func test_decodeErrorEnvelope() throws {
        let json = #"""
        {"ok": false, "data": null,
         "error": {"code": "ACCOUNT_EXISTS", "message": "duplicate"}}
        """#
        let env = try JSONDecoder().decode(
            CLIEnvelope<AccountsListData>.self,
            from: Data(json.utf8)
        )
        XCTAssertFalse(env.ok)
        XCTAssertEqual(env.error?.code, "ACCOUNT_EXISTS")
        XCTAssertEqual(env.error?.message, "duplicate")
        XCTAssertNil(env.data)
    }
}
```

- [ ] **Step 2: Run the test to verify it fails to build**

Run: `cd app && xcodegen generate && xcodebuild test -project AccountPilot.xcodeproj -scheme AccountPilot -derivedDataPath build/ -skipMacroValidation 2>&1 | tail -10 && cd ..`
Expected: build error mentioning `AccountStoreTests` (the file references no missing symbols, so this should actually build green — proceed if so; otherwise read the error carefully).

If both tests build and pass already, that's fine — they verify Task 6's models. Move on.

- [ ] **Step 3: Implement `AccountStore.swift`**

`app/AccountPilot/Services/AccountStore.swift`:

```swift
import Foundation
import SwiftUI

/// ObservableObject backing `AccountsView`. All account state mutations
/// go through `accountpilot ... --json` invocations of the bundled CLI.
/// The CLI is the single source of truth; the store re-fetches after
/// every mutation rather than tracking state independently.
@MainActor
final class AccountStore: ObservableObject {
    @Published private(set) var accounts: [Account] = []
    @Published private(set) var loadError: String?
    @Published private(set) var isLoading: Bool = false

    /// Refresh the list from `accountpilot accounts list --json`.
    func refresh() async {
        isLoading = true
        defer { isLoading = false }
        do {
            let stdout = try await PythonRuntime.shared.run(
                ["-m", "accountpilot.cli", "accounts", "list", "--json"]
            )
            let env = try JSONDecoder().decode(
                CLIEnvelope<AccountsListData>.self,
                from: Data(stdout.utf8)
            )
            if env.ok, let data = env.data {
                self.accounts = data.accounts
                self.loadError = nil
            } else {
                self.loadError = env.error?.message ?? "unknown error"
            }
        } catch {
            self.loadError = "\(error)"
        }
    }

    /// Add a new account. Returns the new account's id on success, or
    /// throws an `AccountStoreError` (carries the CLI's error.code) on
    /// failure. Caller is responsible for triggering OAuth afterward.
    func add(
        provider: String,
        identifier: String,
        ownerName: String,
        ownerSurname: String?
    ) async throws -> Int {
        var args = [
            "-m", "accountpilot.cli", "accounts", "add", "--json",
            "--provider", provider,
            "--identifier", identifier,
            "--owner-name", ownerName,
        ]
        if let s = ownerSurname, !s.isEmpty {
            args.append("--owner-surname")
            args.append(s)
        }
        let stdout = try await PythonRuntime.shared.run(args)

        struct AddData: Decodable { let account: Account }
        let env = try JSONDecoder().decode(
            CLIEnvelope<AddData>.self, from: Data(stdout.utf8)
        )
        guard env.ok, let data = env.data else {
            throw AccountStoreError(
                code: env.error?.code ?? "UNKNOWN",
                message: env.error?.message ?? "add failed"
            )
        }
        await refresh()
        return data.account.id
    }

    /// Remove account by id. Throws on failure.
    func remove(id: Int) async throws {
        let stdout = try await PythonRuntime.shared.run(
            ["-m", "accountpilot.cli", "accounts", "remove", "\(id)", "--json"]
        )
        struct RemoveData: Decodable { let removed_id: Int }
        let env = try JSONDecoder().decode(
            CLIEnvelope<RemoveData>.self, from: Data(stdout.utf8)
        )
        guard env.ok else {
            throw AccountStoreError(
                code: env.error?.code ?? "UNKNOWN",
                message: env.error?.message ?? "remove failed"
            )
        }
        await refresh()
    }

    /// Run an OAuth login for an existing account id; returns the secrets
    /// path on success. The CLI blocks until the user completes consent
    /// in the browser, so this can take 15–60 seconds — caller should
    /// show a spinner.
    func oauthLogin(provider: String, accountID: Int) async throws -> String {
        guard provider == "google" || provider == "microsoft" else {
            throw AccountStoreError(
                code: "UNSUPPORTED_PROVIDER",
                message: "no OAuth flow for provider=\(provider)"
            )
        }
        let stdout = try await PythonRuntime.shared.run([
            "-m", "accountpilot.cli", "oauth", "login", provider,
            "\(accountID)", "--json",
        ])
        struct LoginData: Decodable {
            let account_id: Int
            let provider: String
            let secret_path: String
        }
        let env = try JSONDecoder().decode(
            CLIEnvelope<LoginData>.self, from: Data(stdout.utf8)
        )
        guard env.ok, let data = env.data else {
            throw AccountStoreError(
                code: env.error?.code ?? "OAUTH_FAILED",
                message: env.error?.message ?? "oauth login failed"
            )
        }
        return data.secret_path
    }
}

struct AccountStoreError: Error, LocalizedError {
    let code: String
    let message: String
    var errorDescription: String? { "[\(code)] \(message)" }
}
```

- [ ] **Step 4: Build**

Run: `cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -5 && cd ..`
Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 5: Run XCTest (model decode tests)**

Run: `cd app && xcodebuild test -project AccountPilot.xcodeproj -scheme AccountPilot -derivedDataPath build/ -skipMacroValidation 2>&1 | grep -E "Test Suite|passed|failed" | tail -10 && cd ..`
Expected: both `AccountStoreTests` cases pass. (`PythonRuntimeTests` will still fail per Phase 1's documented limitation — that's expected, ignore them.)

- [ ] **Step 6: Commit**

```bash
git add app/AccountPilot/Services/ app/AccountPilotTests/AccountStoreTests.swift
git commit -m "feat(app): AccountStore — CLI wrapper for accounts CRUD + OAuth"
```

---

## Task 8: Swift `AccountsView.swift` — list + remove

**Goal:** SwiftUI list view bound to `@StateObject AccountStore`. Each row shows source/identifier/enabled state. Swipe-to-delete and a "−" button trigger `store.remove(id:)`. A "+" toolbar button opens the AddAccountSheet (Task 9). For now stub the "+" action; we wire it in Task 9.

**Files:**
- Create: `app/AccountPilot/Views/AccountsView.swift`

- [ ] **Step 1: Write `AccountsView.swift`**

```bash
mkdir -p app/AccountPilot/Views
```

`app/AccountPilot/Views/AccountsView.swift`:

```swift
import SwiftUI

struct AccountsView: View {
    @StateObject private var store = AccountStore()
    @State private var showAddSheet = false
    @State private var pendingError: AccountStoreError?

    var body: some View {
        NavigationStack {
            content
                .navigationTitle("Accounts")
                .toolbar {
                    ToolbarItem(placement: .primaryAction) {
                        Button {
                            showAddSheet = true
                        } label: {
                            Label("Add account", systemImage: "plus")
                        }
                    }
                }
                .sheet(isPresented: $showAddSheet) {
                    AddAccountSheet(store: store)
                }
                .alert(
                    "Could not remove account",
                    isPresented: .constant(pendingError != nil),
                    presenting: pendingError
                ) { _ in
                    Button("OK") { pendingError = nil }
                } message: { err in
                    Text(err.localizedDescription)
                }
                .task { await store.refresh() }
        }
    }

    @ViewBuilder
    private var content: some View {
        if store.isLoading && store.accounts.isEmpty {
            ProgressView("Loading accounts…")
        } else if let err = store.loadError {
            VStack(spacing: 12) {
                Image(systemName: "exclamationmark.triangle")
                    .font(.system(size: 36))
                    .foregroundStyle(.orange)
                Text("Could not load accounts").font(.headline)
                Text(err)
                    .font(.system(.body, design: .monospaced))
                    .multilineTextAlignment(.center)
                    .textSelection(.enabled)
                Button("Retry") { Task { await store.refresh() } }
            }
            .padding()
        } else if store.accounts.isEmpty {
            VStack(spacing: 12) {
                Image(systemName: "tray")
                    .font(.system(size: 40))
                    .foregroundStyle(.secondary)
                Text("No accounts yet").font(.headline)
                Text("Click + to add Gmail, Outlook, or iMessage.")
                    .foregroundStyle(.secondary)
            }
            .padding()
        } else {
            List {
                ForEach(store.accounts) { account in
                    AccountRow(account: account) {
                        await removeAccount(account)
                    }
                }
            }
        }
    }

    private func removeAccount(_ account: Account) async {
        do {
            try await store.remove(id: account.id)
        } catch let err as AccountStoreError {
            pendingError = err
        } catch {
            pendingError = AccountStoreError(code: "UNKNOWN", message: "\(error)")
        }
    }
}

private struct AccountRow: View {
    let account: Account
    let onRemove: () async -> Void

    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: iconForSource(account.source))
                .font(.system(size: 22))
                .foregroundStyle(account.enabled ? Color.accentColor : .secondary)
                .frame(width: 32)
            VStack(alignment: .leading, spacing: 2) {
                Text(account.identifier).font(.body)
                Text("\(account.source) · \(account.ownerName)")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            Spacer()
            Button(role: .destructive) {
                Task { await onRemove() }
            } label: {
                Image(systemName: "minus.circle.fill")
                    .foregroundStyle(.red)
            }
            .buttonStyle(.plain)
        }
        .padding(.vertical, 4)
    }

    private func iconForSource(_ source: String) -> String {
        switch source {
        case "gmail", "outlook", "imap-generic": return "envelope"
        case "imessage": return "message"
        default: return "person.crop.circle"
        }
    }
}
```

- [ ] **Step 2: Add a stub `AddAccountSheet` so AccountsView compiles**

(We write the real sheet in Task 9. Stub it for now.)

`app/AccountPilot/Views/AddAccountSheet.swift`:

```swift
import SwiftUI

/// Stub — real implementation lands in Task 9.
struct AddAccountSheet: View {
    @ObservedObject var store: AccountStore
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack {
            Text("Add Account").font(.title2)
            Text("(coming in next task)")
            Button("Close") { dismiss() }
        }
        .padding(40)
        .frame(minWidth: 400, minHeight: 200)
    }
}
```

- [ ] **Step 3: Build**

Run: `cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -5 && cd ..`
Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 4: Commit**

```bash
git add app/AccountPilot/Views/
git commit -m "feat(app): AccountsView — list + remove (with stub AddAccountSheet)"
```

---

## Task 9: Swift `AddAccountSheet.swift` — provider picker + OAuth driver

**Goal:** Replace the stub with the real "Add account" form. User picks a provider (Gmail / Outlook / iMessage), types the identifier (email / phone / handle) + their name, hits "Add". The view: (1) calls `store.add(...)` to create the row, (2) for gmail/outlook, calls `store.oauthLogin(...)` which spawns the browser for consent, (3) shows a spinner, (4) on success dismisses + the parent's `store.accounts` is already refreshed.

**Files:**
- Modify: `app/AccountPilot/Views/AddAccountSheet.swift`

- [ ] **Step 1: Replace `AddAccountSheet.swift` with real implementation**

```swift
import SwiftUI

struct AddAccountSheet: View {
    @ObservedObject var store: AccountStore
    @Environment(\.dismiss) private var dismiss

    @State private var provider: Provider = .gmail
    @State private var identifier: String = ""
    @State private var ownerName: String = ""
    @State private var ownerSurname: String = ""

    @State private var phase: Phase = .editing
    @State private var errorMessage: String?

    enum Provider: String, CaseIterable, Identifiable {
        case gmail, outlook, imessage
        var id: Self { self }
        var label: String {
            switch self {
            case .gmail: return "Gmail"
            case .outlook: return "Outlook"
            case .imessage: return "iMessage"
            }
        }
        var requiresOAuth: Bool { self == .gmail || self == .outlook }
        var oauthName: String { self == .gmail ? "google" : "microsoft" }
        var identifierPlaceholder: String {
            switch self {
            case .gmail, .outlook: return "you@example.com"
            case .imessage: return "you@icloud.com or +15551234567"
            }
        }
    }

    enum Phase: Equatable {
        case editing
        case creatingAccount
        case awaitingOAuth
        case done
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Add Account").font(.title2.bold())

            Picker("Provider", selection: $provider) {
                ForEach(Provider.allCases) { p in
                    Text(p.label).tag(p)
                }
            }
            .pickerStyle(.segmented)
            .disabled(phase != .editing)

            Form {
                TextField(provider.identifierPlaceholder, text: $identifier)
                    .textContentType(.emailAddress)
                    .autocorrectionDisabled()
                TextField("Your first name", text: $ownerName)
                TextField("Your last name (optional)", text: $ownerSurname)
            }
            .disabled(phase != .editing)

            if let err = errorMessage {
                Text(err)
                    .font(.caption)
                    .foregroundStyle(.red)
                    .textSelection(.enabled)
            }

            HStack {
                if phase == .creatingAccount {
                    ProgressView().controlSize(.small)
                    Text("Creating account…").foregroundStyle(.secondary)
                } else if phase == .awaitingOAuth {
                    ProgressView().controlSize(.small)
                    Text("Waiting for OAuth in your browser…")
                        .foregroundStyle(.secondary)
                }
                Spacer()
                Button("Cancel") { dismiss() }
                    .keyboardShortcut(.cancelAction)
                    .disabled(phase == .awaitingOAuth)
                Button("Add") { Task { await submit() } }
                    .keyboardShortcut(.defaultAction)
                    .disabled(!canSubmit)
            }
        }
        .padding(28)
        .frame(minWidth: 460, minHeight: 320)
    }

    private var canSubmit: Bool {
        guard phase == .editing else { return false }
        return !identifier.trimmingCharacters(in: .whitespaces).isEmpty
            && !ownerName.trimmingCharacters(in: .whitespaces).isEmpty
    }

    private func submit() async {
        errorMessage = nil
        phase = .creatingAccount
        let trimmedIdentifier = identifier.trimmingCharacters(in: .whitespaces)
        let trimmedOwnerName = ownerName.trimmingCharacters(in: .whitespaces)
        let trimmedSurname = ownerSurname.trimmingCharacters(in: .whitespaces)

        let newAccountID: Int
        do {
            newAccountID = try await store.add(
                provider: provider.rawValue,
                identifier: trimmedIdentifier,
                ownerName: trimmedOwnerName,
                ownerSurname: trimmedSurname.isEmpty ? nil : trimmedSurname
            )
        } catch let err as AccountStoreError {
            phase = .editing
            errorMessage = err.localizedDescription
            return
        } catch {
            phase = .editing
            errorMessage = "\(error)"
            return
        }

        guard provider.requiresOAuth else {
            phase = .done
            dismiss()
            return
        }

        phase = .awaitingOAuth
        do {
            _ = try await store.oauthLogin(
                provider: provider.oauthName,
                accountID: newAccountID
            )
            phase = .done
            dismiss()
        } catch let err as AccountStoreError {
            // Account row was created but OAuth failed. Leave the row in
            // place; user can retry via "Re-authenticate" later (Phase 3).
            phase = .editing
            errorMessage = "Account added but OAuth failed: \(err.localizedDescription). " +
                "Remove the account and try again, or re-authenticate later."
        } catch {
            phase = .editing
            errorMessage = "OAuth failed: \(error)"
        }
    }
}
```

- [ ] **Step 2: Build**

Run: `cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -5 && cd ..`
Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 3: Commit**

```bash
git add app/AccountPilot/Views/AddAccountSheet.swift
git commit -m "feat(app): AddAccountSheet — provider picker + OAuth driver"
```

---

## Task 10: Swift `ConfigMigration.swift` — first-launch YAML import

**Goal:** A struct with one method that calls `accountpilot config import --json` exactly once on app launch. If the YAML doesn't exist or has already been renamed, it's a no-op (Task 4 already handles the no-op JSON envelope). Errors get surfaced via a published flag the parent app can show as a banner; we don't block the UI on migration failure.

**Files:**
- Create: `app/AccountPilot/Services/ConfigMigration.swift`
- Create: `app/AccountPilotTests/ConfigMigrationTests.swift`

- [ ] **Step 1: Write the failing test**

`app/AccountPilotTests/ConfigMigrationTests.swift`:

```swift
import XCTest
@testable import AccountPilot

final class ConfigMigrationTests: XCTestCase {

    /// Decode a noop import response.
    func test_decodeNoopImportResponse() throws {
        let json = #"""
        {"ok": true,
         "data": {"accounts_imported": 0, "renamed_to": null, "noop": true},
         "error": null}
        """#
        let env = try JSONDecoder().decode(
            CLIEnvelope<ConfigImportData>.self,
            from: Data(json.utf8)
        )
        XCTAssertTrue(env.ok)
        XCTAssertEqual(env.data?.accountsImported, 0)
        XCTAssertNil(env.data?.renamedTo)
        XCTAssertTrue(env.data?.noop ?? false)
    }

    /// Decode a successful import response.
    func test_decodeSuccessfulImportResponse() throws {
        let json = #"""
        {"ok": true,
         "data": {"accounts_imported": 3,
                  "renamed_to": "/path/config.yaml.imported",
                  "noop": false},
         "error": null}
        """#
        let env = try JSONDecoder().decode(
            CLIEnvelope<ConfigImportData>.self,
            from: Data(json.utf8)
        )
        XCTAssertEqual(env.data?.accountsImported, 3)
        XCTAssertEqual(env.data?.renamedTo, "/path/config.yaml.imported")
        XCTAssertFalse(env.data?.noop ?? true)
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd app && xcodegen generate && xcodebuild test -project AccountPilot.xcodeproj -scheme AccountPilot -derivedDataPath build/ -skipMacroValidation 2>&1 | tail -10 && cd ..`
Expected: build error — `ConfigImportData` not defined.

- [ ] **Step 3: Implement `ConfigMigration.swift`**

`app/AccountPilot/Services/ConfigMigration.swift`:

```swift
import Foundation

/// Decoded shape of `accountpilot config import --json`'s `data` field.
struct ConfigImportData: Decodable {
    let accountsImported: Int
    let renamedTo: String?
    let noop: Bool

    enum CodingKeys: String, CodingKey {
        case accountsImported = "accounts_imported"
        case renamedTo = "renamed_to"
        case noop
    }
}

/// First-launch migration: imports accounts from `config.yaml` (if it
/// exists) into the SQLite DB, then renames the YAML to mark the
/// migration as done. Subsequent launches see no YAML and noop.
///
/// Surface design note: returning the result rather than mutating
/// global state lets `AccountPilotApp` decide what to do with import
/// failures (today: log + continue; later: show a one-time banner).
enum ConfigMigration {

    enum Result: Equatable {
        case skipped                              // YAML missing; nothing to do
        case imported(count: Int)                 // success
        case failed(message: String)              // error
    }

    static func runIfNeeded() async -> Result {
        do {
            let stdout = try await PythonRuntime.shared.run(
                ["-m", "accountpilot.cli", "config", "import", "--json"]
            )
            let env = try JSONDecoder().decode(
                CLIEnvelope<ConfigImportData>.self,
                from: Data(stdout.utf8)
            )
            guard env.ok, let data = env.data else {
                return .failed(message: env.error?.message ?? "unknown migration error")
            }
            if data.noop {
                return .skipped
            }
            return .imported(count: data.accountsImported)
        } catch {
            return .failed(message: "\(error)")
        }
    }
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd app && xcodegen generate && xcodebuild test -project AccountPilot.xcodeproj -scheme AccountPilot -derivedDataPath build/ -skipMacroValidation 2>&1 | grep -E "Test Suite|passed|failed" | tail -10 && cd ..`
Expected: both `ConfigMigrationTests` cases pass.

- [ ] **Step 5: Commit**

```bash
git add app/AccountPilot/Services/ConfigMigration.swift app/AccountPilotTests/ConfigMigrationTests.swift
git commit -m "feat(app): ConfigMigration — first-launch YAML import service"
```

---

## Task 11: Wire ConfigMigration into `AccountPilotApp` + replace ContentView

**Goal:** On launch: run migration before showing the window, then replace the Phase 1 placeholder ContentView with `AccountsView`. Keep a small footer with the version triple so the existing visual identity stays.

**Files:**
- Modify: `app/AccountPilot/AccountPilotApp.swift`
- Modify: `app/AccountPilot/ContentView.swift`

- [ ] **Step 1: Update `AccountPilotApp.swift`**

```swift
import SwiftUI

@main
struct AccountPilotApp: App {
    @State private var migration: ConfigMigration.Result?

    var body: some Scene {
        WindowGroup("AccountPilot") {
            ContentView(migration: migration)
                .frame(minWidth: 720, minHeight: 480)
                .task {
                    if migration == nil {
                        migration = await ConfigMigration.runIfNeeded()
                    }
                }
        }
        .windowResizability(.contentMinSize)
    }
}
```

- [ ] **Step 2: Replace `ContentView.swift`**

```swift
import SwiftUI

struct ContentView: View {
    let migration: ConfigMigration.Result?

    @State private var pythonVersion: String = "loading…"
    @State private var accountpilotVersion: String = "loading…"

    var body: some View {
        VStack(spacing: 0) {
            AccountsView()
            Divider()
            footer
        }
        .task { await loadVersions() }
    }

    @ViewBuilder
    private var footer: some View {
        VStack(spacing: 4) {
            if case .imported(let count) = migration {
                Text("Imported \(count) account(s) from config.yaml")
                    .font(.caption)
                    .foregroundStyle(.green)
            } else if case .failed(let msg) = migration {
                Text("Migration failed: \(msg)")
                    .font(.caption)
                    .foregroundStyle(.orange)
                    .lineLimit(2)
            }
            HStack(spacing: 12) {
                Text("AccountPilot 0.2.0")
                Text("·")
                Text("Python \(pythonVersion)")
                    .font(.system(.caption, design: .monospaced))
                Text("·")
                Text("accountpilot \(accountpilotVersion)")
                    .font(.system(.caption, design: .monospaced))
            }
            .font(.caption)
            .foregroundStyle(.secondary)
        }
        .padding(.vertical, 8)
        .frame(maxWidth: .infinity)
        .background(.ultraThinMaterial)
    }

    private func loadVersions() async {
        if let pv = try? await PythonRuntime.shared.run(
            ["-c", "import platform; print(platform.python_version())"]
        ) {
            pythonVersion = pv.trimmingCharacters(in: .whitespacesAndNewlines)
        }
        if let av = try? await PythonRuntime.shared.run(
            ["-m", "accountpilot.cli", "--version"]
        ) {
            accountpilotVersion = av.trimmingCharacters(in: .whitespacesAndNewlines)
        }
    }
}
```

- [ ] **Step 3: Build**

Run: `cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -5 && cd ..`
Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 4: Commit**

```bash
git add app/AccountPilot/AccountPilotApp.swift app/AccountPilot/ContentView.swift
git commit -m "feat(app): wire ConfigMigration + AccountsView into root scene"
```

---

## Task 12: Bump `[0.2.0]` CHANGELOG with Phase 2 entries

**Files:**
- Modify: `CHANGELOG.md`

- [ ] **Step 1: Read the current `[0.2.0]` section to see what's already there**

```bash
sed -n '/^## \[0.2.0\]/,/^## \[/p' CHANGELOG.md | head -25
```

- [ ] **Step 2: Append Phase 2 entries under the existing `[0.2.0]` heading**

Insert these bullets under the existing `### Added` block (after the Phase 1 macOS .app distribution entry):

```markdown
- **Account management UI.** SwiftUI accounts list view replaces
  `config.yaml` as the source of truth. Add Gmail/Outlook accounts
  via in-app OAuth flow against bundled publisher credentials —
  no terminal interaction, no YAML editing. Remove deletes the
  account row, all its messages, and the OAuth secret file.
- **First-launch config.yaml migration.** Existing 0.1.x installs
  with `~/Library/Application Support/accountpilot/config.yaml`
  see all accounts auto-imported on first launch. The YAML is
  renamed to `config.yaml.imported` once applied so the migration
  is one-shot.
- **CLI: `accountpilot accounts add --json`,
  `accountpilot accounts remove ID --json`,
  `accountpilot config import --json`,
  `accountpilot oauth login {google,microsoft} --json`.** All emit
  a stable `{"ok": bool, "data": ..., "error": ...}` envelope so
  downstream agents (and the SwiftUI app) get a single contract
  for both happy and error paths.
```

- [ ] **Step 3: Commit**

```bash
git add CHANGELOG.md
git commit -m "docs(changelog): Phase 2 — accounts UI + config migration"
```

---

## Task 13: End-to-end build with Phase 2

**Goal:** Run the existing `build-app.sh` to produce a Phase 2 `dist/AccountPilot.app` that includes the new Swift code + the new CLI commands. Run `verify-app.sh`. Drag-to-/Applications smoke test.

This task makes no source changes; it's a verification gate.

- [ ] **Step 1: Build**

Run from repo root:
```bash
rm -rf dist/AccountPilot.app && ./app/scripts/build-app.sh 2>&1 | tee /tmp/build-app-p2.log | tail -20
```

Expected final lines: `==> all verification passed`. Build takes 5–10 minutes (notarization round-trip).

If the build fails at codesign or notarization, this is unexpected — Phase 1's fixes should still cover the bundle. Read `/tmp/build-app-p2.log`'s last 50 lines and diagnose before proceeding.

- [ ] **Step 2: Smoke-test the bundled CLI from the terminal**

```bash
dist/AccountPilot.app/Contents/Resources/bin/accountpilot accounts list --json
```

Expected: a valid envelope. On a clean DB the data is `{"accounts": []}`. If the host machine has a populated `~/Library/Application Support/accountpilot/accountpilot.db`, expect the existing rows.

```bash
dist/AccountPilot.app/Contents/Resources/bin/accountpilot config --help | head -10
```

Expected output to include `import`.

- [ ] **Step 3: Drag-to-/Applications**

```bash
rm -rf /Applications/AccountPilot.app
cp -R dist/AccountPilot.app /Applications/AccountPilot.app
open /Applications/AccountPilot.app
```

Expected:
- Window opens within ~3 seconds
- "Accounts" navigation title at top
- If you have existing accounts (from 0.1.x or earlier), they appear in the list with green icons
- If you don't, the empty-state ("No accounts yet — Click + to add Gmail, Outlook, or iMessage.") shows
- Footer at bottom: `AccountPilot 0.2.0 · Python 3.13.1 · accountpilot accountpilot, version 0.2.0`
- If a `config.yaml` was migrated, a green caption above the footer says `Imported N account(s) from config.yaml`

- [ ] **Step 4: Manual smoke test — Add a Gmail account**

In the running app:
1. Click the "+" button in the toolbar
2. Pick "Gmail" (default)
3. Type your email + name
4. Click "Add"
5. Browser opens for Google consent
6. Approve
7. Sheet closes; the account appears in the list

If anything throws an error in the sheet, copy the visible error message and stop here — don't proceed to Step 5 until that's understood.

- [ ] **Step 5: Manual smoke test — Remove the account**

1. Click the red "−" on the account's row
2. Account disappears from the list
3. Verify the secrets file was deleted:

```bash
ls "$HOME/Library/Application Support/accountpilot/secrets/oauth/google/"
```

Expected: the just-created `<id>.json` is gone (other accounts' files, if any, remain).

- [ ] **Step 6: Verify CLI sees the same state as the UI**

```bash
/Applications/AccountPilot.app/Contents/Resources/bin/accountpilot accounts list --json | python3 -m json.tool
```

Expected: matches the visible UI. (Per master brief verification criterion.)

- [ ] **Step 7: No commit (verification only).**

If anything failed, return to the relevant task (1–11) and fix before declaring Phase 2 done.

---

## Task 14: Push branch + open PR

- [ ] **Step 1: Push**

```bash
git push -u origin 0.2-p2-accounts-ui
```

- [ ] **Step 2: Open PR against `main`**

```bash
gh pr create --title "0.2 Phase 2: Account management UI + config migration" --body "$(cat <<'EOF'
Implements Phase 2 of the 0.2 macOS .app pivot per
\`docs/plans/0.2-p2-accounts-ui.md\`.

## What's in here

### Python CLI (new --json surface)

- \`accountpilot accounts list --json\` — stable envelope
- \`accountpilot accounts add --provider X --identifier Y --owner-name Z [--owner-surname S] --json\`
- \`accountpilot accounts remove ID --json\`
- \`accountpilot oauth login {google,microsoft} ID --json\`
- \`accountpilot config import [--config PATH] [--db-path PATH] --json\`
  — one-shot YAML→DB migration, renames \`config.yaml\` to
  \`.imported\` so subsequent invocations no-op

All commands emit \`{"ok": bool, "data": ..., "error": {"code":...,"message":...}}\` so the Swift app gets a single contract for happy + error paths.

### Swift app

- \`Models/Account.swift\` — Codable mirror of \`accounts list --json\`
- \`Services/AccountStore.swift\` — ObservableObject; CLI is single source of truth
- \`Services/ConfigMigration.swift\` — first-launch YAML import
- \`Views/AccountsView.swift\` — list + remove
- \`Views/AddAccountSheet.swift\` — provider picker + identifier entry + OAuth driver
- \`AccountPilotApp.swift\` — runs migration before window shows
- \`ContentView.swift\` — replaces Phase 1 placeholder with AccountsView + version footer

### Tests

- \`tests/core/cli/test_accounts_cmds_json.py\`, \`test_config_import.py\`, \`test_oauth_login_json.py\`
- \`AccountStoreTests.swift\`, \`ConfigMigrationTests.swift\` (Codable decoding only — Phase 1 \`PythonRuntimeTests\` limitation still applies)

## Verification

- Fresh install (DB cleared, no config.yaml): app opens to empty state, "Add Gmail" → OAuth → row appears.
- Existing 0.1.x install: \`config.yaml\` auto-imports on first launch; rename to \`.imported\` confirmed.
- Removing an account deletes its OAuth secret file at \`secrets/oauth/<provider>/<id>.json\`.
- \`accountpilot accounts list --json\` from terminal matches the in-app list 1-1.

## Phase 3 next

FDA wizard, sync status, background daemon supervision (XPC). New branch \`0.2-p3-fda-sync-daemon\` after merge.
EOF
)"
```

- [ ] **Step 3: Wait for CI / merge.**

---

## Phase 2 done when

- [ ] All Task 1–11 commits land on the branch (12 commits + verification + PR)
- [ ] `pytest tests/core/cli/ -v` is green
- [ ] `xcodebuild test` shows `AccountStoreTests` + `ConfigMigrationTests` green (PythonRuntimeTests still failing per Phase 1 doc — that's the known limit)
- [ ] `./app/scripts/build-app.sh` ends in `==> all verification passed`
- [ ] Manual: drag to /Applications, open, add a Gmail account via the UI, the row appears, removing it removes both the row and the secret file
- [ ] Manual: with a `config.yaml` present, first launch shows the green "Imported N account(s)" caption and the YAML is now `config.yaml.imported`
- [ ] PR open against `main`, all CI green, merged

## Phase 3 starts after

- Phase 2 PR merged
- New branch `0.2-p3-fda-sync-daemon`
- Write `docs/plans/0.2-p3-fda-sync-daemon.md` via `superpowers:writing-plans`
