# 0.2 — Phase 6: Agent-friendly CLI hardening (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:** Make the bundled CLI a first-class agent surface. Every read command emits the standard `{ok, data, error}` JSON envelope on `--json`. Every error path returns a stable exit code from a small documented set. The .app prompts on first launch to install a `/usr/local/bin/accountpilot` symlink so agents reach the bundled CLI by name. `docs/cli-contract.md` documents the full surface; `tests/integration/cli_contract_test.py` validates the real CLI output against the contract.

**Architecture:** Python additions are mechanical — apply the established `_emit_envelope` helper to the two remaining read commands (`people show`, `oauth status`), add a small `core/cli/exit_codes.py` module, and a new `accountpilot completion` command (Click already supports shell completion natively via `click.completion.shell_complete`). The `/usr/local/bin/` symlink is wired by a new Python helper `accountpilot self link` plus a Swift first-launch prompt that calls it. Contract tests run the real CLI against a temp DB and validate stdout JSON against jsonschema documents.

**Tech Stack:** Click + jsonschema (Python tests) · Click's built-in `--shell-completion` for `accountpilot completion <shell>` · Swift `NSWorkspace` for sudo-less symlink (or `osascript` admin if /usr/local/bin/ requires elevated permissions) · existing build/sign/notarize pipeline (no changes)

---

## Prerequisites

- [ ] Phase 5 PR has merged to `main`. Verify: `git log --oneline main | head -3` shows the Phase 5 merge.
- [ ] On a Phase 6 branch: `git checkout -b 0.2-p6-cli-hardening` (off main).
- [ ] `pytest -q` green from main.
- [ ] `dist/AccountPilot.app` from Phase 5 still builds end-to-end.

## File structure

```
src/accountpilot/core/cli/
├── exit_codes.py            # CREATE: integer constants + helper
├── people_cmds.py           # MODIFY: --json on `show`
├── oauth_cmd.py             # MODIFY: --json on `status`
├── completion_cmd.py        # CREATE: `accountpilot completion <shell>`
├── self_cmd.py              # CREATE: `accountpilot self link [--target PATH]`

src/accountpilot/cli.py      # MODIFY: register completion_cmd + self_cmd

tests/accountpilot/unit/cli/
├── test_people_show_json.py # CREATE
├── test_oauth_status_json.py# CREATE
├── test_completion_cmd.py   # CREATE
└── test_self_link.py        # CREATE

tests/integration/
└── cli_contract_test.py     # CREATE: real-CLI ↔ schema validation
└── jsonschemas/             # CREATE: per-command JSON Schemas
    ├── envelope.schema.json
    ├── accounts_list.schema.json
    ├── messages_list.schema.json
    └── …                    # one per documented command

tests/integration/
└── agent_smoke.sh           # CREATE: 50-line bash script — Phase 6 verification

app/AccountPilot/
├── Services/
│   └── CLILinkPrompt.swift  # CREATE: first-launch /usr/local/bin/ install
├── Views/
│   └── CLILinkPromptView.swift  # CREATE: alert + Install button
└── ContentView.swift            # MODIFY: invoke prompt once after FDA

docs/
└── cli-contract.md          # CREATE: public contract for agents

CHANGELOG.md                  # MODIFY: Phase 6 entries
```

---

## Task 1: `accountpilot people show --json`

**Goal:** Detail-pane CLI for one person. Returns identifiers + recent message threads + role distribution.

**Files:**
- Modify: `src/accountpilot/core/cli/people_cmds.py`
- Create: `tests/accountpilot/unit/cli/test_people_show_json.py`

JSON shape:
```json
{
  "ok": true,
  "data": {
    "person": {
      "id": 2, "name": "Charles", "surname": "Babbage",
      "is_owner": false,
      "identifiers": [{"kind": "email", "value": "charles@example.com"}],
      "message_count": 142,
      "last_message_at": "2026-04-15T08:23:00+00:00",
      "roles": {"from": 130, "to": 12}
    }
  },
  "error": null
}
```

Or on unknown id: `{"ok": false, "error": {"code": "PERSON_NOT_FOUND", ...}}`.

- [ ] **Step 1: Write the failing tests** — `tests/accountpilot/unit/cli/test_people_show_json.py`:

```python
"""Tests for `accountpilot people show <id> --json`."""

from __future__ import annotations

import json
from pathlib import Path  # noqa: TC003

import pytest
from click.testing import CliRunner

from accountpilot.core.cli.people_cmds import people_group
from accountpilot.core.db.connection import open_db


@pytest.fixture
async def db_with_charles(tmp_path: Path) -> Path:
    db = tmp_path / "test.db"
    async with open_db(db) as 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 people (id, name, surname, is_owner, "
            "created_at, updated_at) VALUES (2, 'Charles', 'Babbage', 0, "
            "'2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')"
        )
        await conn.execute(
            "INSERT INTO identifiers (id, person_id, kind, value, "
            "is_primary, created_at) VALUES (1, 2, 'email', "
            "'charles@example.com', 1, '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.execute(
            "INSERT INTO messages (id, account_id, source, external_id, "
            "sent_at, body_text, direction, created_at) "
            "VALUES (10, 1, 'gmail', 'rfc-1', "
            "'2026-04-15T08:23:00+00:00', 'hi', 'in', "
            "'2026-04-15T08:23:01+00:00')"
        )
        await conn.execute(
            "INSERT INTO message_people (message_id, person_id, role) "
            "VALUES (10, 2, 'from')"
        )
        await conn.commit()
    return db


def test_people_show_json(db_with_charles: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        people_group, ["show", "2", "--json", "--db-path", str(db_with_charles)]
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["ok"] is True
    p = payload["data"]["person"]
    assert p["id"] == 2
    assert p["name"] == "Charles"
    assert p["surname"] == "Babbage"
    assert p["is_owner"] is False
    assert p["identifiers"] == [
        {"kind": "email", "value": "charles@example.com"}
    ]
    assert p["message_count"] == 1
    assert p["last_message_at"] == "2026-04-15T08:23:00+00:00"
    assert p["roles"] == {"from": 1}


def test_people_show_unknown_id(db_with_charles: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        people_group, ["show", "999", "--json",
                       "--db-path", str(db_with_charles)]
    )
    payload = json.loads(result.output)
    assert payload["ok"] is False
    assert payload["error"]["code"] == "PERSON_NOT_FOUND"
```

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

Run: `pytest tests/accountpilot/unit/cli/test_people_show_json.py -v`
Expected: FAIL.

- [ ] **Step 3: Add `--json` to `people show`**

Read `src/accountpilot/core/cli/people_cmds.py` first to see the existing `people_show` implementation. Add a `@click.option("--json", "json_out", is_flag=True)` flag and a JSON branch. Reuse the existing `_emit_envelope` helper that's already at the top of `people_cmds.py` (added in Phase 4 Task 2).

The JSON branch query (single connection, three queries):

```python
async def _run_json() -> None:
    async with open_db(db_path) as db:
        async with db.execute(
            "SELECT id, name, surname, is_owner FROM people WHERE id = ?",
            (person_id,),
        ) as cur:
            person = await cur.fetchone()
        if person is None:
            _emit_envelope(error={
                "code": "PERSON_NOT_FOUND",
                "message": f"no person with id={person_id}",
            })
            return

        async with db.execute(
            "SELECT kind, value FROM identifiers WHERE person_id = ? "
            "ORDER BY id",
            (person_id,),
        ) as cur:
            idents = [
                {"kind": r["kind"], "value": r["value"]}
                for r in await cur.fetchall()
            ]

        async with db.execute(
            "SELECT mp.role, COUNT(*) AS n, MAX(m.sent_at) AS last_at "
            "FROM message_people mp "
            "JOIN messages m ON m.id = mp.message_id "
            "WHERE mp.person_id = ? GROUP BY mp.role",
            (person_id,),
        ) as cur:
            roles: dict[str, int] = {}
            last_at: str | None = None
            total = 0
            for r in await cur.fetchall():
                roles[r["role"]] = r["n"]
                total += r["n"]
                if r["last_at"] is not None and (last_at is None or r["last_at"] > last_at):
                    last_at = r["last_at"]

    _emit_envelope(data={
        "person": {
            "id": person["id"],
            "name": person["name"],
            "surname": person["surname"],
            "is_owner": bool(person["is_owner"]),
            "identifiers": idents,
            "message_count": total,
            "last_message_at": last_at,
            "roles": roles,
        }
    })


if json_out:
    asyncio.run(_run_json())
    return
# … existing human-readable path stays unchanged below …
```

- [ ] **Step 4: Verify**

```bash
pytest tests/accountpilot/unit/cli/test_people_show_json.py -v
pytest tests/accountpilot/unit/cli/ -q
ruff check src tests
mypy src/accountpilot
```

- [ ] **Step 5: Commit**

```bash
git add src/accountpilot/core/cli/people_cmds.py tests/accountpilot/unit/cli/test_people_show_json.py
git commit -m "feat(cli): people show --json with identifiers + role counts"
```

---

## Task 2: `accountpilot oauth status --json`

**Goal:** Add `--json` to the existing `oauth status` command. Returns presence + last-modified timestamp per provider/account.

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

JSON shape:
```json
{
  "ok": true,
  "data": {
    "tokens": [
      {
        "provider": "google",
        "account_id": 42,
        "secret_path": "/Users/.../secrets/oauth/google/42.json",
        "modified_at": "2026-05-04T12:00:00+00:00"
      }
    ]
  },
  "error": null
}
```

- [ ] **Step 1: Write the failing test** — `tests/accountpilot/unit/cli/test_oauth_status_json.py`:

```python
"""Tests for `accountpilot oauth status --json`."""

from __future__ import annotations

import json
from pathlib import Path  # noqa: TC003

from click.testing import CliRunner

from accountpilot.core.cli.oauth_cmd import oauth_group


def test_oauth_status_json_empty(tmp_path: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        oauth_group,
        ["status", "--json", "--secrets-root", str(tmp_path)],
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload == {
        "ok": True,
        "data": {"tokens": []},
        "error": None,
    }


def test_oauth_status_json_with_secrets(tmp_path: Path) -> None:
    secrets = tmp_path / "oauth"
    g = secrets / "google"
    g.mkdir(parents=True)
    (g / "42.json").write_text('{"refresh_token": "x"}')

    runner = CliRunner()
    result = runner.invoke(
        oauth_group, ["status", "--json", "--secrets-root", str(tmp_path)]
    )
    payload = json.loads(result.output)
    assert payload["ok"] is True
    tokens = payload["data"]["tokens"]
    assert len(tokens) == 1
    t = tokens[0]
    assert t["provider"] == "google"
    assert t["account_id"] == 42
    assert t["secret_path"].endswith("oauth/google/42.json")
    assert t["modified_at"] is not None
```

- [ ] **Step 2: Run to verify FAIL**

Run: `pytest tests/accountpilot/unit/cli/test_oauth_status_json.py -v`
Expected: FAIL.

- [ ] **Step 3: Add `--json` to `oauth_status`**

Read `oauth_cmd.py`'s existing `status` function. Add a `@click.option("--json", "json_out", is_flag=True)` and:

```python
import os
from datetime import UTC, datetime


@oauth_group.command("status")
@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 status(secrets_root: Path, json_out: bool) -> None:
    """List which oauth secret files are present."""
    base = secrets_root / "oauth"

    if json_out:
        tokens: list[dict[str, Any]] = []
        if base.exists():
            for provider_dir in sorted(base.iterdir()):
                if not provider_dir.is_dir():
                    continue
                for f in sorted(provider_dir.glob("*.json")):
                    try:
                        account_id = int(f.stem)
                    except ValueError:
                        continue
                    mtime = datetime.fromtimestamp(
                        f.stat().st_mtime, tz=UTC
                    ).isoformat()
                    tokens.append({
                        "provider": provider_dir.name,
                        "account_id": account_id,
                        "secret_path": str(f),
                        "modified_at": mtime,
                    })
        _emit_envelope(data={"tokens": tokens})
        return

    # … existing human-readable path stays unchanged below …
```

- [ ] **Step 4: Verify**

```bash
pytest tests/accountpilot/unit/cli/test_oauth_status_json.py -v
pytest tests/accountpilot/unit/cli/ -q
ruff check src tests
mypy src/accountpilot
```

- [ ] **Step 5: Commit**

```bash
git add src/accountpilot/core/cli/oauth_cmd.py tests/accountpilot/unit/cli/test_oauth_status_json.py
git commit -m "feat(cli): oauth status --json"
```

---

## Task 3: Stable exit codes module + apply across error paths

**Goal:** A small module of integer constants + a helper, applied to every error path that should be agent-distinguishable.

**Files:**
- Create: `src/accountpilot/core/cli/exit_codes.py`
- Modify: any file that currently raises `click.ClickException` or `click.UsageError` with a stable code in `error.code`

The exit code matrix (per the master brief):
- `0` — success
- `2` — usage error (Click's default for bad flags)
- `13` — permission denied (FDA, OAuth)
- `64` — config error (malformed YAML, missing required field)
- `65` — data error (corrupt DB, schema mismatch, message not found)
- `69` — external service unavailable (IMAP server down, OAuth token revoked)

Maps from existing `error.code` strings:
- `PERSON_NOT_FOUND` / `MESSAGE_NOT_FOUND` / `ACCOUNT_NOT_FOUND` / `ATTACHMENT_NOT_FOUND` → 65 (data error)
- `ACCOUNT_EXISTS` → 65 (caller asked for an invalid state)
- `OAUTH_FAILED` → 69 (external service)
- `SYNC_FAILED` → 69 (external service)
- Anything Click rejects natively → 2

- [ ] **Step 1: Write the failing test** — `tests/accountpilot/unit/cli/test_exit_codes.py`:

```python
"""Tests for stable exit codes across CLI error paths."""

from __future__ import annotations

from pathlib import Path  # noqa: TC003

import pytest
from click.testing import CliRunner

from accountpilot.core.cli.accounts_cmds import accounts_group
from accountpilot.core.cli.messages_cmds import attachments_group, messages_group
from accountpilot.core.db.connection import open_db


@pytest.fixture
async def empty_db(tmp_path: Path) -> Path:
    db = tmp_path / "test.db"
    async with open_db(db):
        pass
    return db


def test_messages_get_unknown_exits_65(empty_db: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        messages_group, ["get", "999", "--json", "--db-path", str(empty_db)]
    )
    assert result.exit_code == 65, result.output


def test_attachments_path_unknown_exits_65(empty_db: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        attachments_group,
        ["path", "999", "--json", "--db-path", str(empty_db)],
    )
    assert result.exit_code == 65, result.output


def test_accounts_remove_unknown_exits_65(empty_db: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        accounts_group,
        ["remove", "999", "--json", "--db-path", str(empty_db)],
    )
    assert result.exit_code == 65, result.output


def test_accounts_add_duplicate_exits_65(empty_db: Path) -> None:
    runner = CliRunner()
    runner.invoke(
        accounts_group,
        ["add", "--json", "--provider", "gmail",
         "--identifier", "ada@example.com",
         "--owner-name", "Ada", "--db-path", str(empty_db)],
    )
    result = runner.invoke(
        accounts_group,
        ["add", "--json", "--provider", "gmail",
         "--identifier", "ada@example.com",
         "--owner-name", "Ada", "--db-path", str(empty_db)],
    )
    assert result.exit_code == 65, result.output
```

- [ ] **Step 2: Run to verify FAIL**

Run: `pytest tests/accountpilot/unit/cli/test_exit_codes.py -v`
Expected: FAIL — current commands exit 0 even on `ok: false` envelope.

- [ ] **Step 3: Create `exit_codes.py`**

`src/accountpilot/core/cli/exit_codes.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.

"""Stable exit codes for the AccountPilot CLI.

Documented in docs/cli-contract.md. Agents key off these values to
distinguish error categories without parsing stderr or stdout.
"""

from __future__ import annotations

SUCCESS = 0
USAGE_ERROR = 2          # Click default for bad flags / args
PERMISSION_DENIED = 13   # FDA denied, OAuth flow refused
CONFIG_ERROR = 64        # malformed YAML, missing required field
DATA_ERROR = 65          # corrupt DB, schema mismatch, missing row
SERVICE_UNAVAILABLE = 69 # IMAP server down, OAuth token revoked


_CODE_MAP: dict[str, int] = {
    "PERSON_NOT_FOUND": DATA_ERROR,
    "MESSAGE_NOT_FOUND": DATA_ERROR,
    "ACCOUNT_NOT_FOUND": DATA_ERROR,
    "ATTACHMENT_NOT_FOUND": DATA_ERROR,
    "ACCOUNT_EXISTS": DATA_ERROR,
    "OAUTH_FAILED": SERVICE_UNAVAILABLE,
    "SYNC_FAILED": SERVICE_UNAVAILABLE,
    "FDA_DENIED": PERMISSION_DENIED,
    "HELPER_MISSING": SERVICE_UNAVAILABLE,
}


def for_error_code(code: str) -> int:
    """Map an envelope `error.code` string to the documented exit code.

    Falls back to DATA_ERROR (65) for unrecognized codes — better
    than 0 (which would falsely indicate success).
    """
    return _CODE_MAP.get(code, DATA_ERROR)
```

- [ ] **Step 4: Apply across error paths**

For each command that emits an error envelope, after `_emit_envelope(error=...)` add a `sys.exit(...)` with the right code:

In `messages_cmds.py` (in `messages_get` after `_emit_envelope(error=result["error"])`):

```python
if json_out:
    if result["ok"]:
        _emit_envelope(data={"message": result["message"]})
    else:
        _emit_envelope(error=result["error"])
        sys.exit(exit_codes.for_error_code(result["error"]["code"]))
    return
```

Same pattern for `messages_get`, `attachments_path`, `accounts_remove`, `accounts_add`. The pattern is: after emitting the error envelope, `sys.exit(exit_codes.for_error_code(error["code"]))` so Click doesn't return 0.

Add `import sys` and `from accountpilot.core.cli import exit_codes` to each module.

- [ ] **Step 5: Verify**

```bash
pytest tests/accountpilot/unit/cli/test_exit_codes.py -v
pytest tests/accountpilot/unit/cli/ -q   # no regressions
ruff check src tests
mypy src/accountpilot
```

- [ ] **Step 6: Commit**

```bash
git add src/accountpilot/core/cli/exit_codes.py src/accountpilot/core/cli/messages_cmds.py src/accountpilot/core/cli/accounts_cmds.py tests/accountpilot/unit/cli/test_exit_codes.py
git commit -m "feat(cli): stable exit codes for envelope error.code values"
```

---

## Task 4: `accountpilot completion <shell>`

**Goal:** Emit shell-completion scripts on demand. Click 8 supports this natively via `<command>--show-completion`. Wrap it in a friendlier subcommand.

**Files:**
- Create: `src/accountpilot/core/cli/completion_cmd.py`
- Modify: `src/accountpilot/cli.py` (register `completion_cmd`)
- Create: `tests/accountpilot/unit/cli/test_completion_cmd.py`

- [ ] **Step 1: Write the failing test** — `tests/accountpilot/unit/cli/test_completion_cmd.py`:

```python
"""Tests for `accountpilot completion <shell>`."""

from __future__ import annotations

from click.testing import CliRunner

from accountpilot.core.cli.completion_cmd import completion_cmd


def test_completion_bash_emits_script() -> None:
    runner = CliRunner()
    result = runner.invoke(completion_cmd, ["bash"])
    assert result.exit_code == 0, result.output
    assert "_accountpilot_completion" in result.output


def test_completion_zsh_emits_script() -> None:
    runner = CliRunner()
    result = runner.invoke(completion_cmd, ["zsh"])
    assert result.exit_code == 0, result.output
    assert "_accountpilot_completion" in result.output


def test_completion_invalid_shell_exits_2() -> None:
    runner = CliRunner()
    result = runner.invoke(completion_cmd, ["fish"])
    # Click's bash/zsh/fish are all supported actually; invalid is e.g. "csh"
    result2 = runner.invoke(completion_cmd, ["csh"])
    assert result2.exit_code == 2, result2.output
```

- [ ] **Step 2: Run to verify FAIL**

Run: `pytest tests/accountpilot/unit/cli/test_completion_cmd.py -v`
Expected: FAIL — module does not exist.

- [ ] **Step 3: Implement**

`src/accountpilot/core/cli/completion_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.

"""accountpilot completion — emit shell completion scripts."""

from __future__ import annotations

import os

import click
from click.shell_completion import get_completion_class


@click.command("completion")
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
def completion_cmd(shell: str) -> None:
    """Emit a shell completion script.

    Usage:
      accountpilot completion bash >> ~/.bash_completion
      accountpilot completion zsh  >> ~/.zfunc/_accountpilot
      accountpilot completion fish > ~/.config/fish/completions/accountpilot.fish
    """
    cls = get_completion_class(shell)
    if cls is None:
        raise click.UsageError(f"unsupported shell: {shell}")

    # Click expects to discover the root command via the current invocation;
    # we synthesize that environment so `accountpilot completion bash` emits
    # the right script for `accountpilot`.
    from accountpilot.cli import cli as root_cli

    instance = cls(
        cli=root_cli,
        ctx_args={},
        prog_name="accountpilot",
        complete_var="_ACCOUNTPILOT_COMPLETE",
    )
    click.echo(instance.source())
```

In `src/accountpilot/cli.py`, add the import + registration:

```python
from accountpilot.core.cli.completion_cmd import completion_cmd
...
cli.add_command(completion_cmd)
```

- [ ] **Step 4: Verify**

```bash
pytest tests/accountpilot/unit/cli/test_completion_cmd.py -v
pytest tests/accountpilot/unit/cli/ -q
ruff check src tests
mypy src/accountpilot
python3 -m accountpilot.cli completion bash | head -10
```

The bash output should be a multi-line shell function definition.

- [ ] **Step 5: Commit**

```bash
git add src/accountpilot/core/cli/completion_cmd.py tests/accountpilot/unit/cli/test_completion_cmd.py src/accountpilot/cli.py
git commit -m "feat(cli): accountpilot completion <shell> for bash/zsh/fish"
```

---

## Task 5: `accountpilot self link` + Swift first-launch prompt

**Goal:** Bridge the bundled CLI to `/usr/local/bin/accountpilot`. Two pieces:
1. **Python `accountpilot self link`** — does the actual symlink + auto-detects the bundle root via `sys.executable` walking up.
2. **Swift `CLILinkPrompt`** — on first launch, asks "Install command-line tools to /usr/local/bin?" and runs the Python helper if user accepts.

**Files:**
- Create: `src/accountpilot/core/cli/self_cmd.py`
- Modify: `src/accountpilot/cli.py` (register `self_group`)
- Create: `tests/accountpilot/unit/cli/test_self_link.py`
- Create: `app/AccountPilot/Services/CLILinkPrompt.swift`
- Modify: `app/AccountPilot/ContentView.swift` (invoke prompt once after FDA)

JSON shape (Python):
```json
{
  "ok": true,
  "data": {
    "source": "/Applications/AccountPilot.app/Contents/Resources/bin/accountpilot",
    "target": "/usr/local/bin/accountpilot",
    "created": true
  },
  "error": null
}
```

`created: false` if the symlink was already there pointing at the right place. `error.code: "TARGET_EXISTS"` if a different file (not symlink to our bin) is at the target.

- [ ] **Step 1: Write the failing test** — `tests/accountpilot/unit/cli/test_self_link.py`:

```python
"""Tests for `accountpilot self link`."""

from __future__ import annotations

import json
import os
from pathlib import Path  # noqa: TC003

from click.testing import CliRunner

from accountpilot.core.cli.self_cmd import self_group


def test_self_link_creates_symlink(tmp_path: Path) -> None:
    source = tmp_path / "src" / "accountpilot"
    source.parent.mkdir()
    source.write_text("#!/bin/bash\necho hello\n")
    source.chmod(0o755)

    target = tmp_path / "bin" / "accountpilot"
    target.parent.mkdir()

    runner = CliRunner()
    result = runner.invoke(
        self_group,
        ["link", "--json", "--source", str(source), "--target", str(target)],
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["ok"] is True
    assert payload["data"]["created"] is True
    assert target.is_symlink()
    assert os.readlink(target) == str(source)


def test_self_link_idempotent(tmp_path: Path) -> None:
    source = tmp_path / "src" / "accountpilot"
    source.parent.mkdir()
    source.write_text("#!/bin/bash\necho hello\n")
    source.chmod(0o755)

    target = tmp_path / "bin" / "accountpilot"
    target.parent.mkdir()
    target.symlink_to(source)

    runner = CliRunner()
    result = runner.invoke(
        self_group,
        ["link", "--json", "--source", str(source), "--target", str(target)],
    )
    payload = json.loads(result.output)
    assert payload["ok"] is True
    assert payload["data"]["created"] is False  # already there


def test_self_link_target_exists_as_file(tmp_path: Path) -> None:
    source = tmp_path / "accountpilot"
    source.write_text("#!/bin/bash\necho src\n")
    source.chmod(0o755)
    target = tmp_path / "bin" / "accountpilot"
    target.parent.mkdir()
    target.write_text("not a symlink")

    runner = CliRunner()
    result = runner.invoke(
        self_group,
        ["link", "--json", "--source", str(source), "--target", str(target)],
    )
    payload = json.loads(result.output)
    assert payload["ok"] is False
    assert payload["error"]["code"] == "TARGET_EXISTS"
```

- [ ] **Step 2: Run to verify FAIL**

Run: `pytest tests/accountpilot/unit/cli/test_self_link.py -v`
Expected: FAIL — module does not exist.

- [ ] **Step 3: Implement `self_cmd.py`**

`src/accountpilot/core/cli/self_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.

"""accountpilot self link — bridge bundled CLI to /usr/local/bin/."""

from __future__ import annotations

import json
import os
import sys
from pathlib import Path
from typing import Any

import click


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


def _default_source() -> Path:
    """Path to the bundled CLI shim, derived from sys.executable.

    sys.executable is the bundled python3; walk up to the .app's
    Resources/bin/accountpilot.
    """
    interp = Path(sys.executable).resolve()
    # Resources/python/runtime/bin/python3 → Resources/bin/accountpilot
    return interp.parent.parent.parent.parent / "bin" / "accountpilot"


@click.group("self")
def self_group() -> None:
    """Operations on the AccountPilot install itself."""


@self_group.command("link")
@click.option("--source", type=click.Path(path_type=Path),
              default=_default_source,
              show_default="<bundle>/Contents/Resources/bin/accountpilot")
@click.option("--target", type=click.Path(path_type=Path),
              default=Path("/usr/local/bin/accountpilot"),
              show_default="/usr/local/bin/accountpilot")
@click.option("--json", "json_out", is_flag=True)
def self_link(source: Path, target: Path, json_out: bool) -> None:
    """Symlink target → source. Idempotent; safe to re-run."""
    if not source.exists():
        if json_out:
            _emit_envelope(error={
                "code": "SOURCE_MISSING",
                "message": f"source CLI not found at {source}",
            })
            sys.exit(65)
        raise click.ClickException(f"source CLI not found at {source}")

    target.parent.mkdir(parents=True, exist_ok=True)

    if target.is_symlink():
        # Already a symlink — verify it points where we want.
        if os.readlink(target) == str(source):
            if json_out:
                _emit_envelope(data={
                    "source": str(source),
                    "target": str(target),
                    "created": False,
                })
                return
            click.echo(f"already linked: {target} → {source}")
            return
        # Wrong target — replace.
        target.unlink()
    elif target.exists():
        # Regular file/dir blocking the path.
        if json_out:
            _emit_envelope(error={
                "code": "TARGET_EXISTS",
                "message": (
                    f"{target} exists and is not a symlink — refusing to "
                    f"overwrite. Remove it manually if intentional."
                ),
            })
            sys.exit(65)
        raise click.ClickException(f"{target} exists and is not a symlink")

    target.symlink_to(source)

    if json_out:
        _emit_envelope(data={
            "source": str(source),
            "target": str(target),
            "created": True,
        })
        return
    click.echo(f"linked: {target} → {source}")
```

In `src/accountpilot/cli.py`:

```python
from accountpilot.core.cli.self_cmd import self_group
...
cli.add_command(self_group)
```

- [ ] **Step 4: Verify Python**

```bash
pytest tests/accountpilot/unit/cli/test_self_link.py -v
pytest tests/accountpilot/unit/cli/ -q
ruff check src tests
mypy src/accountpilot
```

- [ ] **Step 5: Implement `CLILinkPrompt.swift`**

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

```swift
import Foundation
import SwiftUI

/// One-shot prompt that asks the user whether to install
/// /usr/local/bin/accountpilot. Stores acceptance/decline in
/// UserDefaults so the prompt only fires once.
@MainActor
final class CLILinkPrompt: ObservableObject {
    @Published var shouldShow: Bool = false
    @Published private(set) var lastError: String?

    private let promptedKey = "cliLinkPrompted"

    func checkOnLaunch() {
        if UserDefaults.standard.bool(forKey: promptedKey) {
            return
        }
        // Only prompt if the symlink doesn't already exist.
        let target = URL(fileURLWithPath: "/usr/local/bin/accountpilot")
        if FileManager.default.fileExists(atPath: target.path) {
            UserDefaults.standard.set(true, forKey: promptedKey)
            return
        }
        shouldShow = true
    }

    /// User clicked Install. Run `accountpilot self link --json`.
    func install() async {
        defer { shouldShow = false; UserDefaults.standard.set(true, forKey: promptedKey) }
        do {
            let stdout = try await PythonRuntime.shared.run(
                ["-m", "accountpilot.cli", "self", "link", "--json"]
            )
            struct Env: Decodable {
                struct Data: Decodable { let created: Bool }
                let ok: Bool; let data: Data?
                struct Err: Decodable { let code: String; let message: String }
                let error: Err?
            }
            let env = try JSONDecoder().decode(Env.self, from: Data(stdout.utf8))
            if !env.ok {
                lastError = env.error?.message ?? "unknown error"
            }
        } catch {
            lastError = "\(error)"
        }
    }

    /// User clicked Skip. Mark prompted so we don't ask again.
    func decline() {
        UserDefaults.standard.set(true, forKey: promptedKey)
        shouldShow = false
    }
}
```

- [ ] **Step 6: Modify `ContentView.swift` to wire the prompt after FDA**

Read the current `ContentView.swift`. Add a `@StateObject private var cliPrompt = CLILinkPrompt()` and trigger `cliPrompt.checkOnLaunch()` in the existing post-FDA branch. Add `.alert(...)` modifier showing "Install command-line tools?" with Install/Skip buttons.

Approximate diff:

```swift
struct ContentView: View {
    let migration: ConfigMigration.Result?
    @ObservedObject var supervisor: SyncSupervisor

    @State private var didProbeFDA: Bool = false
    @State private var fdaGranted: Bool = false
    @StateObject private var cliPrompt = CLILinkPrompt()

    var body: some View {
        Group {
            if !didProbeFDA {
                ProgressView("Checking permissions…")
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            } else if !fdaGranted {
                FDAWizardView(onGranted: {
                    fdaGranted = true
                    cliPrompt.checkOnLaunch()
                })
            } else {
                VStack(spacing: 0) {
                    BrowseView()
                    Divider()
                    footer
                }
                .alert(
                    "Install command-line tools?",
                    isPresented: $cliPrompt.shouldShow
                ) {
                    Button("Install") {
                        Task { await cliPrompt.install() }
                    }
                    Button("Skip", role: .cancel) {
                        cliPrompt.decline()
                    }
                } message: {
                    Text("This creates a symlink at /usr/local/bin/accountpilot so you can run AccountPilot from any terminal. You can remove it later with `rm /usr/local/bin/accountpilot`.")
                }
            }
        }
        .task {
            let result = await FDAProbe.probe()
            fdaGranted = result.granted
            didProbeFDA = true
            if fdaGranted {
                cliPrompt.checkOnLaunch()
            }
        }
    }

    // … existing footer view …
}
```

- [ ] **Step 7: Build**

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -3 && cd ..
```

Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 8: Note about `/usr/local/bin/` write permissions**

`/usr/local/bin/` may be owned by root on systems without Homebrew (which sets it to user-writable). If `target.symlink_to(source)` fails with EACCES, the Python command's error envelope says so but currently has no escalation path. For Phase 6, that's acceptable — the user can `sudo ln -sf /Applications/AccountPilot.app/Contents/Resources/bin/accountpilot /usr/local/bin/accountpilot` manually. Document this limitation in `docs/cli-contract.md` (Task 6).

- [ ] **Step 9: Commit**

```bash
git add src/accountpilot/core/cli/self_cmd.py tests/accountpilot/unit/cli/test_self_link.py src/accountpilot/cli.py app/AccountPilot/Services/CLILinkPrompt.swift app/AccountPilot/ContentView.swift
git commit -m "feat(cli): self link + first-launch /usr/local/bin/ prompt"
```

---

## Task 6: `docs/cli-contract.md`

**Goal:** Public agent-facing contract documenting every command, JSON schema, exit codes, and stability guarantees.

**Files:**
- Create: `docs/cli-contract.md`

The doc has these sections:
1. **Stability promise** — describes what changes are breaking vs. backwards-compat
2. **Exit codes** — the matrix from Task 3
3. **Envelope shape** — `{ok, data, error}` definition
4. **Per-command reference** — one entry per agent-facing read command:
   - Synopsis (signature)
   - Schema reference (`tests/integration/jsonschemas/<cmd>.schema.json`)
   - Example invocation + output
   - Possible error codes

- [ ] **Step 1: Write the doc**

`docs/cli-contract.md`:

```markdown
# AccountPilot CLI agent contract

Version: 0.2.0

This document describes the AccountPilot CLI's public surface for
agents and automation. Every read command emits the standard JSON
envelope on `--json`. Exit codes are stable. Schemas live in
`tests/integration/jsonschemas/`.

## Stability promise

- **Backwards-compatible additions** (new optional flags, new fields
  on `data` payloads, new `error.code` values) can land in any
  release. Agents that ignore unknown fields stay working.
- **Backwards-incompatible changes** (renamed/removed flags, removed
  `data` fields, changed exit codes) only happen at major version
  bumps and are listed under `### Removed` in the CHANGELOG.

## Exit codes

| Code | Meaning |
|---|---|
| 0   | success |
| 2   | usage error (Click's default for bad flags/args) |
| 13  | permission denied (FDA, OAuth) |
| 64  | config error (malformed YAML, missing required field) |
| 65  | data error (corrupt DB, schema mismatch, missing row, duplicate) |
| 69  | external service unavailable (IMAP server down, OAuth token revoked) |

The mapping from envelope `error.code` strings to exit codes is in
`src/accountpilot/core/cli/exit_codes.py`.

## Envelope

Every `--json` invocation emits a single line of JSON:

```json
{"ok": true,  "data": <command-specific>, "error": null}
{"ok": false, "data": null,                "error": {"code": "STRING", "message": "human-readable"}}
```

`ok` is the boolean truth of the operation. `data` is null on error;
`error` is null on success. Always-present fields keep parsers simple.

## Read commands

### `accountpilot accounts list --json`

**Schema:** `tests/integration/jsonschemas/accounts_list.schema.json`

Returns all accounts in the local DB.

```bash
$ accountpilot accounts list --json
{"ok": true, "data": {"accounts": [{"id": 1, "source": "gmail", …}]}, "error": null}
```

**Errors:** none (always returns ok with possibly empty list).

---

### `accountpilot people list --json`

**Schema:** `tests/integration/jsonschemas/people_list.schema.json`

Returns people sorted by message_count DESC. Optional `--owners` filter.

```bash
$ accountpilot people list --json --owners
{"ok": true, "data": {"people": [{"id": 1, "name": "Ada", "is_owner": true, …}]}, "error": null}
```

---

### `accountpilot people show <id> --json`

**Schema:** `tests/integration/jsonschemas/people_show.schema.json`

Returns a single person with identifiers + message stats.

**Errors:**
- `PERSON_NOT_FOUND` (exit 65) — id doesn't exist

---

### `accountpilot messages list [filters] --json`

**Schema:** `tests/integration/jsonschemas/messages_list.schema.json`

Paginated newest-first message list. Filters: `--account ID`,
`--contact-id ID`, `--since YYYY-MM-DD`, `--limit N` (default 50,
max 500), `--cursor ID` (paginate by id < cursor).

`next_cursor` in the response is `null` when fewer than `limit` rows
returned (= last page).

---

### `accountpilot messages get <id> --json`

**Schema:** `tests/integration/jsonschemas/messages_get.schema.json`

Returns full message body + email/imessage discriminated fields +
people refs + attachment metadata. The email/imessage subtree is
exactly one of `null` (the other is null), keying off `source`.

**Errors:**
- `MESSAGE_NOT_FOUND` (exit 65)

---

### `accountpilot attachments path <id> --json`

**Schema:** `tests/integration/jsonschemas/attachments_path.schema.json`

Resolves an attachment id to its absolute filesystem path. The path
is opaque to the agent — the CLI is the source of truth for CAS
layout.

**Errors:**
- `ATTACHMENT_NOT_FOUND` (exit 65)

`exists: false` in the data payload if the row exists but the CAS
file is missing on disk.

---

### `accountpilot search "<query>" [--limit N] --json`

**Schema:** `tests/integration/jsonschemas/search.schema.json`

FTS5 BM25-ranked search. Lower `score` = more relevant. Default
limit 20.

```bash
$ accountpilot search "fazla" --json
{"ok": true, "data": {"query": "fazla", "results": [{"id": 42, "score": -1.43, …}]}, "error": null}
```

---

### `accountpilot status --json`

**Schema:** `tests/integration/jsonschemas/status.schema.json`

Per-account sync state (last_sync_at, last_error, synced_count) +
generated_at timestamp.

---

### `accountpilot oauth status --json`

**Schema:** `tests/integration/jsonschemas/oauth_status.schema.json`

Lists OAuth secret files present per provider/account.

---

### `accountpilot config import [--config PATH] [--db-path PATH] --json`

**Schema:** `tests/integration/jsonschemas/config_import.schema.json`

One-shot YAML→DB migration. Idempotent (noop on missing or
already-imported YAML). Renames `config.yaml` to
`config.yaml.imported` once applied.

## Write commands

These are agent-callable but their `--json` emits a thinner ack
envelope. Most have `--json` already; some don't (in which case the
exit code + stderr are the contract).

### `accountpilot accounts add --provider X --identifier Y --owner-name Z [--owner-surname S] --json`

Creates an account row + owner (find-or-create by name).

**Errors:**
- `ACCOUNT_EXISTS` (exit 65) — `(provider, identifier)` already in DB

### `accountpilot accounts remove ID --json`

Deletes account + cascades messages + sync_status. No confirmation.

**Errors:**
- `ACCOUNT_NOT_FOUND` (exit 65)

### `accountpilot oauth login {google,microsoft} <id> --json`

Runs the interactive OAuth flow. Blocks until consent (or timeout)
in the user's browser. Persists the refresh token.

**Errors:**
- `OAUTH_FAILED` (exit 69)

### `accountpilot sync-once {mail,imessage} <id> --json`

One-shot sync for one account. Returns `synced_count_delta` +
`duration_seconds`.

**Errors:**
- `SYNC_FAILED` (exit 69)

## Agent recipes

### Smoke test: add a Gmail account, sync, search

```bash
#!/usr/bin/env bash
set -euo pipefail

# 1. Add the account.
ADD_OUT="$(accountpilot accounts add --provider gmail \
  --identifier ada@example.com --owner-name Ada --owner-surname Lovelace --json)"
ACCOUNT_ID="$(echo "$ADD_OUT" | jq -r '.data.account.id')"

# 2. Run OAuth (opens browser, blocks until user consent).
accountpilot oauth login google "$ACCOUNT_ID" --json > /dev/null

# 3. Sync once.
accountpilot sync-once mail "$ACCOUNT_ID" --json > /dev/null

# 4. Search.
accountpilot search "interview" --limit 3 --json | jq '.data.results[].subject'
```

This recipe is exercised by `tests/integration/agent_smoke.sh`
(Phase 6 verification).

## CLI installation

The bundled CLI lives at:

```
/Applications/AccountPilot.app/Contents/Resources/bin/accountpilot
```

Run `accountpilot self link` (or accept the first-launch prompt) to
create `/usr/local/bin/accountpilot` as a symlink so agents and shell
users can call `accountpilot` directly.

If `/usr/local/bin/` is root-owned (no Homebrew), the symlink fails
with `error.code = "PERMISSION_DENIED"`. Run manually with sudo:

```bash
sudo ln -sf /Applications/AccountPilot.app/Contents/Resources/bin/accountpilot \
  /usr/local/bin/accountpilot
```

## Schema reference

JSON Schemas for every documented command live in
`tests/integration/jsonschemas/`. Each file is a Draft 7 schema
validating the entire envelope (including `{ok, error}` cross-fields
where relevant). The `tests/integration/cli_contract_test.py` test
runs each command against a fixture DB and validates the real output
against its schema.

## Changelog

Breaking changes to this contract land at major version bumps only.
See `CHANGELOG.md` for the full release history.
```

- [ ] **Step 2: Commit**

```bash
git add docs/cli-contract.md
git commit -m "docs: cli-contract.md — public agent surface"
```

---

## Task 7: `tests/integration/cli_contract_test.py` — schema validation

**Goal:** Real-CLI integration test that validates each documented command's output against its JSON Schema.

**Files:**
- Create: `tests/integration/cli_contract_test.py`
- Create: `tests/integration/jsonschemas/*.json` (one per command)

- [ ] **Step 1: Add jsonschema as a dev dep**

`pyproject.toml`'s `[project.optional-dependencies].dev`:

```toml
dev = [
  ...,
  "jsonschema>=4.20",
]
```

Run: `pip install -e ".[dev]"` to pick it up.

- [ ] **Step 2: Write the envelope schema** — `tests/integration/jsonschemas/envelope.schema.json`:

```json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "AccountPilot CLI envelope",
  "type": "object",
  "required": ["ok", "data", "error"],
  "additionalProperties": false,
  "properties": {
    "ok": { "type": "boolean" },
    "data": { "type": ["object", "null"] },
    "error": {
      "type": ["object", "null"],
      "required": ["code", "message"],
      "additionalProperties": false,
      "properties": {
        "code": { "type": "string" },
        "message": { "type": "string" }
      }
    }
  }
}
```

- [ ] **Step 3: Write per-command schemas**

For each documented read command, write a schema that extends the envelope and constrains the `data` shape. Example for `accounts list`:

`tests/integration/jsonschemas/accounts_list.schema.json`:

```json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "accountpilot accounts list --json",
  "type": "object",
  "required": ["ok", "data", "error"],
  "properties": {
    "ok": { "const": true },
    "error": { "type": "null" },
    "data": {
      "type": "object",
      "required": ["accounts"],
      "properties": {
        "accounts": {
          "type": "array",
          "items": {
            "type": "object",
            "required": ["id", "source", "identifier", "enabled", "owner_id", "owner_name"],
            "properties": {
              "id": { "type": "integer" },
              "source": { "type": "string" },
              "identifier": { "type": "string" },
              "enabled": { "type": "boolean" },
              "owner_id": { "type": "integer" },
              "owner_name": { "type": "string" }
            }
          }
        }
      }
    }
  }
}
```

Repeat for: `messages_list`, `messages_get`, `attachments_path`, `search`, `status`, `oauth_status`, `people_list`, `people_show`, `config_import`.

(For brevity in this plan, only `accounts_list.schema.json` is shown in full. The implementer writes one schema per command from Task 6's contract — the structure is consistent.)

- [ ] **Step 4: Write `tests/integration/cli_contract_test.py`**

```python
"""Integration tests: real CLI output ↔ documented schemas.

Runs each documented read command against a fixture DB, captures
stdout, and validates against the matching schema in jsonschemas/.
"""

from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

import pytest
import jsonschema


SCHEMA_DIR = Path(__file__).parent / "jsonschemas"
REPO_ROOT = Path(__file__).parent.parent.parent


def _load_schema(name: str) -> dict:
    return json.loads((SCHEMA_DIR / f"{name}.schema.json").read_text())


@pytest.fixture
def populated_db(tmp_path: Path) -> Path:
    """A DB seeded with one owner + one account + one message + one attachment."""
    import asyncio
    from accountpilot.core.db.connection import open_db

    db = tmp_path / "test.db"

    async def _seed() -> None:
        async with open_db(db) as 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.execute(
                "INSERT INTO messages (id, account_id, source, external_id, "
                "sent_at, body_text, direction, created_at) "
                "VALUES (10, 1, 'gmail', 'rfc-1', "
                "'2026-04-15T08:23:00+00:00', 'fazla', 'in', "
                "'2026-04-15T08:23:01+00:00')"
            )
            await conn.execute(
                "INSERT INTO email_details (message_id, subject, "
                "imap_uid, mailbox) VALUES (10, 'Subject', 1, 'INBOX')"
            )
            await conn.commit()
    asyncio.run(_seed())
    return db


def _run(args: list[str], db: Path) -> dict:
    """Run the CLI and parse stdout JSON. Asserts exit 0."""
    cmd = [sys.executable, "-m", "accountpilot.cli"] + args + ["--db-path", str(db)]
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
    assert result.returncode == 0, f"CLI failed: {result.stderr}"
    return json.loads(result.stdout)


@pytest.mark.parametrize("schema_name,args", [
    ("accounts_list", ["accounts", "list", "--json"]),
    ("people_list", ["people", "list", "--json"]),
    ("messages_list", ["messages", "list", "--json"]),
    ("search", ["search", "fazla", "--json"]),
    ("status", ["status", "--json"]),
])
def test_cli_output_matches_schema(populated_db: Path, schema_name: str, args: list[str]) -> None:
    payload = _run(args, populated_db)
    schema = _load_schema(schema_name)
    jsonschema.validate(instance=payload, schema=schema)


def test_messages_get_schema(populated_db: Path) -> None:
    payload = _run(["messages", "get", "10", "--json"], populated_db)
    jsonschema.validate(instance=payload, schema=_load_schema("messages_get"))


def test_people_show_schema(populated_db: Path) -> None:
    payload = _run(["people", "show", "1", "--json"], populated_db)
    jsonschema.validate(instance=payload, schema=_load_schema("people_show"))
```

- [ ] **Step 5: Run the tests**

```bash
pytest tests/integration/cli_contract_test.py -v
```

Expected: all parameterized cases + 2 explicit tests pass.

- [ ] **Step 6: Commit**

```bash
git add tests/integration/cli_contract_test.py tests/integration/jsonschemas/ pyproject.toml
git commit -m "test(cli): contract validation against published JSON schemas"
```

---

## Task 8: Agent integration smoke script

**Goal:** A 50-line shell script that uses ONLY the CLI to add a Gmail account, trigger a sync, and search the resulting inbox. Lives in `tests/integration/agent_smoke.sh`, runnable manually.

**Files:**
- Create: `tests/integration/agent_smoke.sh`

This is the "agent integration smoke test" the master brief calls for. It's NOT in pytest — it depends on a real Gmail account + OAuth consent, which can't be automated.

- [ ] **Step 1: Write the script**

```bash
#!/usr/bin/env bash
# Agent integration smoke: simulates an agent doing the full
# add-account → OAuth → sync → search loop using only the CLI.
#
# Required: a real Gmail account and your willingness to do the
# OAuth consent flow in the browser. Set ACCOUNTPILOT_DATA_DIR
# to a tmp dir so this doesn't pollute your real install.
#
# Usage:
#   ACCOUNTPILOT_DATA_DIR=/tmp/agent-smoke \
#       tests/integration/agent_smoke.sh ada@example.com Ada Lovelace

set -euo pipefail

EMAIL="${1:?usage: agent_smoke.sh <email> <first> <last>}"
FIRST="${2:?missing first name}"
LAST="${3:?missing last name}"

CLI="${ACCOUNTPILOT_CLI:-accountpilot}"

echo "==> 1. Add account"
ADD_OUT="$("$CLI" accounts add --provider gmail \
    --identifier "$EMAIL" --owner-name "$FIRST" --owner-surname "$LAST" --json)"
echo "$ADD_OUT" | python3 -m json.tool
ACCOUNT_ID="$(echo "$ADD_OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["account"]["id"])')"
echo "    new account id: $ACCOUNT_ID"

echo "==> 2. OAuth login (browser opens, user consents)"
"$CLI" oauth login google "$ACCOUNT_ID" --json | python3 -m json.tool

echo "==> 3. Sync once"
"$CLI" sync-once mail "$ACCOUNT_ID" --json | python3 -m json.tool

echo "==> 4. List a few messages"
"$CLI" messages list --account "$ACCOUNT_ID" --limit 3 --json \
    | python3 -m json.tool

echo "==> 5. Search the inbox"
"$CLI" search "interview" --limit 3 --json \
    | python3 -m json.tool

echo "==> done"
```

- [ ] **Step 2: Make executable**

```bash
chmod +x tests/integration/agent_smoke.sh
```

- [ ] **Step 3: bash -n check (don't actually run it — needs real OAuth)**

```bash
bash -n tests/integration/agent_smoke.sh
```

Expected: silent.

- [ ] **Step 4: Document usage in `docs/cli-contract.md`**

Add a paragraph under the existing "Smoke test" recipe pointing to `tests/integration/agent_smoke.sh` for the runnable version.

- [ ] **Step 5: Commit**

```bash
git add tests/integration/agent_smoke.sh docs/cli-contract.md
git commit -m "test(cli): agent_smoke.sh — runnable add→oauth→sync→search smoke"
```

---

## Task 9: CHANGELOG + end-to-end build + smoke + PR

- [ ] **Step 1: Append to CHANGELOG.md** under existing `[0.2.0]` `### Added`:

```markdown
- **Agent-friendly CLI hardening.** `--json` on every read command:
  `people show`, `oauth status` (the two that lacked it). Stable exit
  codes (0/2/13/64/65/69) — every error envelope's `error.code` maps
  to a documented exit code in `src/accountpilot/core/cli/exit_codes.py`.
  `accountpilot completion <bash|zsh|fish>` emits shell completion
  scripts. `accountpilot self link` creates a `/usr/local/bin/`
  symlink; first launch of the .app prompts for it.
- **`docs/cli-contract.md`** — public agent contract: per-command
  schemas, exit-code matrix, stability promise.
  `tests/integration/cli_contract_test.py` validates real CLI output
  against JSON Schemas in `tests/integration/jsonschemas/`.
  `tests/integration/agent_smoke.sh` is a runnable add→OAuth→sync→
  search smoke (manual; requires real Gmail + browser).
```

- [ ] **Step 2: End-to-end build**

```bash
rm -rf dist/AccountPilot.app
./app/scripts/build-app.sh > /tmp/p6-build.log 2>&1; echo "exit=$?"
tail -25 /tmp/p6-build.log
```

Expected: `==> all verification passed`.

- [ ] **Step 3: Bundled-CLI sanity**

```bash
dist/AccountPilot.app/Contents/Resources/bin/accountpilot completion bash | head -5
dist/AccountPilot.app/Contents/Resources/bin/accountpilot self --help | head -10
dist/AccountPilot.app/Contents/Resources/bin/accountpilot people show 1 --json 2>&1 | head -3
```

The `people show 1 --json` may return PERSON_NOT_FOUND (exit 65) if the local DB has no row id 1 — that's fine; the test is "the command exists and emits an envelope".

- [ ] **Step 4: Manual smoke — first-launch CLI prompt**

```bash
defaults delete com.accountpilot.app cliLinkPrompted 2>/dev/null || true
osascript -e 'tell application "AccountPilot" to quit' 2>/dev/null
rm -rf /Applications/AccountPilot.app
cp -R dist/AccountPilot.app /Applications/AccountPilot.app
open /Applications/AccountPilot.app
```

Expected: after the FDA probe completes (instant if FDA is already granted), the alert "Install command-line tools?" appears. Click "Install". The .app calls `accountpilot self link --json` and creates `/usr/local/bin/accountpilot`.

Verify:
```bash
ls -la /usr/local/bin/accountpilot
/usr/local/bin/accountpilot --version
```

If `/usr/local/bin/` is root-owned (no Homebrew on a fresh Mac), the symlink fails with EACCES. The .app's alert should surface the error message — verify by checking `lastError` is set (the alert closes either way; in Phase 7 we'd add a follow-up sudo prompt).

- [ ] **Step 5: Commit CHANGELOG**

```bash
git add CHANGELOG.md
git commit -m "docs(changelog): Phase 6 — CLI hardening + contract"
```

- [ ] **Step 6: Push + PR**

```bash
git push -u origin 0.2-p6-cli-hardening
gh pr create --title "0.2 Phase 6: Agent-friendly CLI hardening" --body "$(cat <<'EOF'
Implements Phase 6 of the 0.2 macOS .app pivot per
\`docs/plans/0.2-p6-cli-contract.md\`. Final phase.

## What's in here

### Python CLI
- \`--json\` added to \`people show\` and \`oauth status\` — completes the read-command JSON coverage from Phases 1-5.
- \`src/accountpilot/core/cli/exit_codes.py\` — stable exit codes (0/2/13/64/65/69) keyed off envelope \`error.code\`. Applied across \`messages get\`, \`messages remove\`, \`accounts add\`, \`accounts remove\`, \`attachments path\`.
- \`accountpilot completion <bash|zsh|fish>\` — emits shell completion scripts.
- \`accountpilot self link\` — symlinks \`/usr/local/bin/accountpilot\` to the bundled CLI. Idempotent.

### Swift app
- \`Services/CLILinkPrompt.swift\` — first-launch \"Install command-line tools?\" alert wired in \`ContentView\` after the FDA probe.

### Documentation + tests
- \`docs/cli-contract.md\` — public agent surface contract: per-command shapes, exit code matrix, stability promise.
- \`tests/integration/jsonschemas/\` — Draft 7 schemas for every documented read command.
- \`tests/integration/cli_contract_test.py\` — validates real CLI stdout against the schemas.
- \`tests/integration/agent_smoke.sh\` — runnable add→OAuth→sync→search smoke (manual; needs real Gmail).

## Verification

- \`./app/scripts/build-app.sh\` ends in \`==> all verification passed\` (15 checks unchanged).
- pytest unit suite green; \`pytest tests/integration/cli_contract_test.py\` green.
- Manual: copied to /Applications, first-launch alert appears, "Install" creates \`/usr/local/bin/accountpilot\` symlink.

## Phase 7+ (post-0.2.0)

- LaunchAgent for true sync survival across force-quit (deferred from Phase 3 per scope note)
- Drop the brew formula entirely (per master brief decision log)
- Sparkle-managed point releases via the \`v*\` workflow shipped in Phase 5
EOF
)"
```

---

## Phase 6 done when

- [ ] All 8 implementation task commits land + verification + PR (~10 commits total)
- [ ] `pytest tests/accountpilot/unit/cli/ tests/integration/ -q` green
- [ ] `ruff check src tests` clean; `mypy src/accountpilot` clean
- [ ] `./app/scripts/build-app.sh` ends in `==> all verification passed`
- [ ] Manual: drag to /Applications, first-launch CLI prompt appears, "Install" creates `/usr/local/bin/accountpilot`, `accountpilot --version` works from terminal
- [ ] PR open against `main`, CI green, merged

## After Phase 6

This is the last phase of the 0.2 .app pivot. Tag `v0.2.0` to trigger the Phase 5 release workflow:

```bash
git tag v0.2.0 && git push --tags
```

Confirm via the GitHub Actions tab that `release-app.yml` runs to completion and the DMG appears at https://github.com/aren13/accountpilot/releases/tag/v0.2.0.
