# Implementation Plan: REQ-004 — CLI Self-Update

## Domain Research

- **Problem type:** Integration (subprocess invocation, HTTP API call, environment detection)
- **Approaches evaluated:**
  1. **PyPI JSON API + subprocess upgrade** — query `https://pypi.org/pypi/ievo/json` via httpx, compare versions, run the appropriate installer command via `subprocess.run`. Simple, no extra dependencies. This is what pip itself recommends for version checking.
  2. **pip `install --upgrade` with no check** — always run the upgrade command and let the installer decide. Downside: subprocess runs even when already current, slower, noisier.
  3. **`importlib.metadata` + packaging library for version parsing** — use `packaging.version.Version` for semver comparison. Robust but adds a dependency.
- **Recommended approach:** Option 1 — PyPI JSON API + subprocess. Version comparison can use simple string equality (`current == latest`) since PyPI normalizes version strings. No need for semver parsing unless pre-release versions are involved (out of scope per REQ-004).
- **Libraries/tools:**
  - `httpx` (already in dependencies, v0.27+) — for PyPI API call
  - `subprocess` (stdlib) — for running upgrade commands
  - `sys` (stdlib) — for `sys.argv[0]` path inspection
  - `pathlib` (stdlib) — for path analysis
- **Known pitfalls:**
  - `sys.argv[0]` can be empty string, `None`-ish, or a relative path in some edge cases (e.g., `python -m ievo`). Must handle gracefully.
  - PyPI API can return pre-release versions in certain configurations (but `info.version` returns only the latest stable release, so this is safe).
  - `subprocess.run` can raise `FileNotFoundError` if the installer binary is missing from PATH. Must catch and report.
  - When running under `uvx`, there is no persistent installation to upgrade. The correct action is to advise, not execute.
  - The `uv cache clean ievo` command is informational for `uvx` — the tool auto-fetches latest on next run.

## Architecture Assessment

- **Fits existing architecture:** Yes. This replaces the body of an existing command. No new modules required (the core logic is small enough to live in `src/ievo/core/updater.py` as a pure-function module, callable from tests without Typer).
- **New patterns introduced:** None. Uses existing console helpers, httpx (already used for registry), subprocess (already used in docker.py).
- **Modules affected:**
  - `src/ievo/commands/update.py` — body replaced entirely
  - `src/ievo/core/updater.py` — new module (core logic)
  - `src/ievo/cli.py` — one line change (remove DEPRECATED label)
  - `tests/test_update.py` — replaced entirely
  - `docs/commands.md` — update docs to reflect new behavior
- **Risk level:** Low. Self-contained change. No shared interfaces modified. No database or state changes.
- **Estimated complexity:** S (small)

## Pre-conditions

- [x] REQ-000 must be implemented (dependency) — confirmed implemented
- [x] `httpx` must be in dependencies — confirmed in pyproject.toml
- [x] `src/ievo/version.py` must exist with `__version__` — confirmed

## Sub-tasks

### TASK-004-1: Core updater module (logic + tests)
**Scope:** Create `src/ievo/core/updater.py` with three pure functions: `detect_installer()`, `fetch_latest_version()`, `run_upgrade()`. Write comprehensive tests for all three.
**Tests:** ~14
**Files:** `src/ievo/core/updater.py`, `tests/test_updater.py`
**Time:** ~12 min

### TASK-004-2: Wire command + CLI cleanup + docs
**Scope:** Replace `src/ievo/commands/update.py` body to call updater functions. Remove DEPRECATED label from `cli.py`. Replace `tests/test_update.py`. Update `docs/commands.md`. Apply CR-001 changes to REQ-002 spec.
**Tests:** ~8
**Files:** `src/ievo/commands/update.py`, `src/ievo/cli.py`, `tests/test_update.py`, `docs/commands.md`, `.ievo/spec/requirements/REQ-002-deprecate-cli-commands.md` (minor update per CR-001)
**Depends on:** TASK-004-1
**Time:** ~10 min

## Micro-Steps

### TASK-004-1: Core updater module

#### Step 1: Create `src/ievo/core/updater.py` with `detect_installer()`

Create the updater module with the installer detection function.

**Function signature:**
```python
from enum import Enum

class Installer(str, Enum):
    UVX = "uvx"
    UV_TOOL = "uv tool"
    PIP = "pip"

def detect_installer() -> Installer:
    """Detect how ievo was installed by inspecting sys.argv[0]."""
```

**Detection logic (in priority order):**
1. Read `sys.argv[0]` and resolve to absolute path
2. If path contains `.cache/uv/` or contains `uvx` -> `Installer.UVX`
3. If path is under `~/.local/share/uv/tools/ievo/` (check with `str(path)`) -> `Installer.UV_TOOL`
4. Fallback -> `Installer.PIP`

**Edge case:** If `sys.argv[0]` is empty or cannot be resolved, fall back to `Installer.PIP`.

**Tests to write** (in `tests/test_updater.py`):
1. `test_detect_installer_uvx_cache_path` — `sys.argv[0]` = `/Users/x/.cache/uv/...` -> `Installer.UVX`
2. `test_detect_installer_uvx_in_path` — `sys.argv[0]` contains `uvx` -> `Installer.UVX`
3. `test_detect_installer_uv_tool` — `sys.argv[0]` = `/Users/x/.local/share/uv/tools/ievo/bin/ievo` -> `Installer.UV_TOOL`
4. `test_detect_installer_pip_fallback` — `sys.argv[0]` = `/usr/local/bin/ievo` -> `Installer.PIP`
5. `test_detect_installer_empty_argv` — `sys.argv[0]` = `""` -> `Installer.PIP`

- **Criteria covered:** Implicit (installer detection is a precondition for AC-2, AC-3, AC-4)
- **Files:** create `src/ievo/core/updater.py`, create `tests/test_updater.py`
- **Commit:** `REQ-004: step 1 — installer detection with tests`

#### Step 2: Add `fetch_latest_version()` to updater module

**Function signature:**
```python
async def fetch_latest_version() -> str:
    """Fetch the latest version of ievo from PyPI. Raises httpx.HTTPError on failure."""
```

**Logic:**
1. `httpx.AsyncClient().get("https://pypi.org/pypi/ievo/json", timeout=10.0)`
2. `response.raise_for_status()`
3. `return response.json()["info"]["version"]`

Use `httpx.AsyncClient` with a context manager for proper cleanup.

**Tests to write** (add to `tests/test_updater.py`):
6. `test_fetch_latest_version_success` — mock httpx response with `{"info": {"version": "0.2.0"}}` -> returns `"0.2.0"`
7. `test_fetch_latest_version_network_error` — mock httpx to raise `httpx.ConnectError` -> raises `httpx.HTTPError`
8. `test_fetch_latest_version_http_error` — mock httpx to return 500 -> raises `httpx.HTTPStatusError`

**Mocking boundary:** httpx network calls are a true external boundary — mocking is correct here per project convention (D-013).

- **Criteria covered:** AC-5 (network error path)
- **Files:** modify `src/ievo/core/updater.py`, modify `tests/test_updater.py`
- **Commit:** `REQ-004: step 2 — fetch latest version from PyPI`

#### Step 3: Add `run_upgrade()` to updater module

**Function signature:**
```python
import subprocess

def run_upgrade(installer: Installer) -> subprocess.CompletedProcess[str]:
    """Run the appropriate upgrade command. Raises FileNotFoundError if binary missing."""
```

**Commands by installer:**
- `Installer.UV_TOOL` -> `subprocess.run(["uv", "tool", "upgrade", "ievo"], check=True, capture_output=True, text=True)`
- `Installer.PIP` -> `subprocess.run(["pip", "install", "--upgrade", "ievo"], check=True, capture_output=True, text=True)`
- `Installer.UVX` -> should NOT be called for UVX. Add a `ValueError` guard: `raise ValueError("UVX does not support upgrade — it auto-fetches latest")`

**Tests to write** (add to `tests/test_updater.py`):
9. `test_run_upgrade_uv_tool` — mock `subprocess.run`, verify called with `["uv", "tool", "upgrade", "ievo"]`
10. `test_run_upgrade_pip` — mock `subprocess.run`, verify called with `["pip", "install", "--upgrade", "ievo"]`
11. `test_run_upgrade_uvx_raises` — call with `Installer.UVX` -> raises `ValueError`
12. `test_run_upgrade_binary_not_found` — mock `subprocess.run` to raise `FileNotFoundError` -> propagates

**Mocking boundary:** subprocess calls are a true external boundary — mocking is correct.

- **Criteria covered:** AC-2 (uv tool subprocess), AC-3 (pip subprocess)
- **Files:** modify `src/ievo/core/updater.py`, modify `tests/test_updater.py`
- **Commit:** `REQ-004: step 3 — run upgrade subprocess`

#### Step 4: Add `get_current_version()` helper

**Function signature:**
```python
def get_current_version() -> str:
    """Return the currently installed ievo version."""
    from ievo import __version__
    return __version__
```

This is trivial but encapsulates the import for testability and keeps the command module clean.

**Tests to write:**
13. `test_get_current_version_returns_string` — verify it returns a non-empty string
14. `test_get_current_version_matches_module` — verify it equals `ievo.__version__`

- **Criteria covered:** AC-1 (current version display)
- **Files:** modify `src/ievo/core/updater.py`, modify `tests/test_updater.py`
- **Commit:** `REQ-004: step 4 — get_current_version helper`

### TASK-004-2: Wire command + CLI cleanup + docs

#### Step 5: Replace `src/ievo/commands/update.py`

Replace the entire body of `update.py`. The new command function:

```python
def update() -> None:
    """Update ievo to the latest version."""
```

**No arguments.** No `typer.Argument`. No `require_project()`. No `deprecated_command()`.

**Logic:**
1. `step("Checking for updates...")`
2. `current = get_current_version()`
3. `installer = detect_installer()`
4. If `installer == Installer.UVX`:
   - Print informational message (per REQ-004 user-facing behavior for uvx)
   - `raise typer.Exit(0)`
5. Try `latest = asyncio.run(fetch_latest_version())` — catch `httpx.HTTPError` and `Exception`:
   - `error("Could not check for updates — version check failed.")`
   - `raise typer.Exit(1)`
6. If `current == latest`:
   - `success(f"ievo {current} is already up to date.")`
   - `raise typer.Exit(0)`
7. `info(f"Current version: {current}")`
8. `info(f"Latest version:  {latest}")`
9. `step(f"Detected installer: {installer.value}")`
10. Try `run_upgrade(installer)`:
    - `success(f"ievo updated to {latest}.")`
    - Catch `FileNotFoundError`: `error(f"Could not find {installer.value} — is it installed?")`; `raise typer.Exit(1)`
    - Catch `subprocess.CalledProcessError`: `error("Upgrade command failed.")` + show stderr; `raise typer.Exit(1)`

**Imports to add:** `asyncio`, `subprocess`, `typer`, and from `ievo.core.updater` the four functions + `Installer` enum.

**Imports to remove:** `Path`, `require_project`, `ProjectManifest`, `Registry`.

**Tests to write** (replace `tests/test_update.py` entirely):
1. `test_update_already_up_to_date` — mock `get_current_version` -> `"0.1.0"`, mock `fetch_latest_version` -> `"0.1.0"`, mock `detect_installer` -> `PIP`. Assert no subprocess called, success message, exit 0. **Covers AC-1.**
2. `test_update_uv_tool_upgrade` — current `"0.1.0"`, latest `"0.2.0"`, installer `UV_TOOL`. Assert `subprocess.run` called with correct args, success message. **Covers AC-2.**
3. `test_update_pip_upgrade` — current `"0.1.0"`, latest `"0.2.0"`, installer `PIP`. Assert subprocess called. **Covers AC-3.**
4. `test_update_uvx_informational` — installer `UVX`. Assert no subprocess called, informational message printed, exit 0. **Covers AC-4.**
5. `test_update_network_error` — mock `fetch_latest_version` to raise `httpx.ConnectError`. Assert error message, exit 1. **Covers AC-5.**
6. `test_update_no_deprecated_warning` — verify `deprecated_command` is NOT called anywhere in the update flow. **Covers negative AC: no deprecated_command().**
7. `test_update_no_project_required` — run update without any `.ievo/manifest.yaml` existing. Assert it does NOT call `require_project()` or fail. **Covers negative AC: no project required.**
8. `test_update_subprocess_failure` — mock `run_upgrade` to raise `subprocess.CalledProcessError`. Assert error message, exit 1. **Edge case.**

- **Criteria covered:** AC-1, AC-2, AC-3, AC-4, AC-5, negative ACs
- **Files:** replace `src/ievo/commands/update.py`, replace `tests/test_update.py`
- **Commit:** `REQ-004: step 5 — new self-update command`

#### Step 6: Remove DEPRECATED label from `cli.py` + update docs

**In `src/ievo/cli.py`:**

Change line 45:
```python
app.command(name="update", help="[dim][DEPRECATED][/dim] Update agent — managed by HR agent.")(
    update.update
)
```
To:
```python
app.command(name="update", help="Update ievo to the latest version.")(update.update)
```

**In `docs/commands.md`:**
- Move `ievo update` from the "Deprecated Commands" section to the "Active Commands" section
- Add new documentation matching the user-facing behavior from REQ-004
- Remove the old `### ievo update [agent] -- managed by HR agent` entry

**In `.ievo/spec/requirements/REQ-002-deprecate-cli-commands.md`** (if it exists):
- Add a note that `ievo update` is no longer deprecated per CR-001
- Do NOT modify any other deprecated commands

**Tests to write** (add to `tests/test_cli_app.py`):
9. `test_update_help_no_deprecated_label` — instantiate a Typer `CliRunner`, run `--help`, assert that the line for `update` does NOT contain `DEPRECATED`. **Covers AC-6.**

- **Criteria covered:** AC-6
- **Files:** modify `src/ievo/cli.py`, modify `docs/commands.md`, modify `tests/test_cli_app.py`
- **Commit:** `REQ-004: step 6 — remove deprecated label, update docs`

## Traceability

| Acceptance Criterion | Covered in Step |
|---------------------|-----------------|
| AC-1: Already up to date -> prints message, exit 0, no subprocess | Step 5 (test 1) |
| AC-2: Newer version + uv tool -> runs subprocess, prints success | Step 3 (unit), Step 5 (test 2) |
| AC-3: Newer version + pip -> runs subprocess, prints success | Step 3 (unit), Step 5 (test 3) |
| AC-4: uvx -> informational message, no subprocess, exit 0 | Step 5 (test 4) |
| AC-5: Network error -> error message, exit 1 | Step 2 (unit), Step 5 (test 5) |
| AC-6: --help shows update without DEPRECATED | Step 6 (test 9) |
| Negative: no deprecated_command() | Step 5 (test 6) |
| Negative: no project required | Step 5 (test 7) |
| Negative: no agent update behavior | Step 5 (all — old code replaced) |
| Negative: no Docker/sandbox | Step 5 (all — no Docker imports) |
| Negative: no arguments accepted | Step 5 (function signature has no args) |

## Scope Guard

- **Considered but excluded:**
  - Semver-aware comparison (e.g., pre-release handling) — out of scope. String equality is sufficient for stable releases.
  - `--check` flag (check without upgrading) — not in REQ-004. Can be added later as a separate REQ.
  - `--force` flag (reinstall even if current) — not in REQ-004.
  - Automatic update checking on every `ievo` invocation — not in REQ-004. Would be a separate feature.
  - Updating docs/configuration.md — only `docs/commands.md` is affected. The configuration doc covers `config` command settings, not `update`.
  - Windows-specific path detection (e.g., `AppData\Local\uv\`) — not in scope. Can be handled in a future cross-platform REQ if needed.

## Architecture Notes for Coder

1. **Create `src/ievo/core/updater.py` as the core module.** Keep all logic here — the command file (`update.py`) should be a thin Typer wrapper that calls updater functions and handles user output. This separation makes the logic testable without Typer.

2. **Use `Installer` as a `str, Enum`** — this allows both `installer.value` for display and enum comparison. The `str` mixin means `f"Detected: {installer.value}"` works cleanly.

3. **For httpx, use `httpx.AsyncClient` inside `fetch_latest_version()`** with `async with` for proper cleanup. The command calls `asyncio.run()` to bridge sync Typer -> async httpx. This is the same pattern used in the existing registry code.

4. **Mock boundaries in tests:**
   - `subprocess.run` — mock at `ievo.core.updater.subprocess.run`
   - `httpx.AsyncClient.get` — mock at `ievo.core.updater.httpx.AsyncClient` or use `httpx.MockTransport`
   - `sys.argv` — use `monkeypatch.setattr` or `unittest.mock.patch`
   - Do NOT mock `detect_installer()`, `fetch_latest_version()`, or `run_upgrade()` in unit tests of the updater module. Only mock them in the command-level tests (`test_update.py`) where the command is the unit under test.

5. **Do not import or reference** `require_project`, `ProjectManifest`, `Registry`, `deprecated_command`, or any agent-related code in the new `update.py`. This is a clean break.

6. **Reuse `src/ievo/utils/console.py` helpers** — `step()`, `success()`, `error()`, `info()`, `warn()`. Do not create custom print functions.

7. **The `test_update_help_no_deprecated_label` test in Step 6** should use `typer.testing.CliRunner` to invoke `app` with `["--help"]` and check the output. Example pattern:
   ```python
   from typer.testing import CliRunner
   from ievo.cli import app
   runner = CliRunner()
   result = runner.invoke(app, ["--help"])
   # Find the line containing "update" and assert "DEPRECATED" is not in it
   ```

8. **CR-001 impact on REQ-002:** The only change needed in REQ-002's spec file is a note that `ievo update` is carved out. The actual code change (removing DEPRECATED label) is in Step 6. Do NOT modify the deprecation logic for the other 10 commands.
