# AGENTS.md — Codebase guide for AI agents

## Project overview

Python CLI tool that manages IsardVDI virtual desktops on the Gencat educational
platform (`elmeuescriptori.gestioeducativa.gencat.cat`).  Authentication goes
through a SAML 2.0 flow via Azure Entra ID; subsequent API calls use an
IsardVDI JWT.

## Tech stack

| Concern | Choice |
|---|---|
| Package manager | `uv` |
| CLI framework | `typer` + `rich` |
| HTTP client | `httpx` |
| HTML/XML parsing | `beautifulsoup4` + `lxml` |
| Env vars / credentials | `python-dotenv` (`.env` file) |
| Python version | ≥ 3.14 |
| Tests | `pytest` |

## Repository layout

```
isard/
├── main.py                   # Development wrapper (imports from src.isard.cli)
├── pyproject.toml
├── .env                      # GENCAT_USERNAME, GENCAT_PASSWORD (never commit)
├── src/isard/
│   ├── __init__.py           # Public API re-exports
│   ├── cli.py                # CLI entry point (typer app)
│   ├── auth.py               # Gencat SAML 2.0 + Azure Entra ID auth flow
│   ├── client.py             # Low-level httpx HTTP client (GencatClient)
│   ├── errors.py             # Exception hierarchy
│   ├── isardvdi.py           # IsardVdiClient — all IsardVDI API calls
│   ├── saml.py               # SAML XML parser helpers
│   ├── session_cache.py      # Persist/load sessions as JSON (~/.config/isard/)
│   └── types.py              # Dataclasses: Desktop, AuthSession, IsardVdiSession,
│                             #   BastionTarget, BastionSsh, BastionHttp, UserInfo,
│                             #   Template (for desktop creation)
└── tests/
    ├── test_auth_helpers.py  # Unit tests (144 lines) — no network
    ├── test_saml.py          # Unit tests (219 lines) — no network
    └── test_integration.py   # Integration tests (334 lines) — skipped without credentials
```

## Session caching

Two session files, both `chmod 600`:

| File | Contents |
|---|---|
| `~/.config/isard/session.json` | Gencat SAML `AuthSession` (user info, cookies, expiry) |
| `~/.config/isard/isardvdi.json` | IsardVDI JWT `IsardVdiSession` (token, expiry) |

Sessions are reused automatically.  Any command that receives HTTP 401 clears
the IsardVDI cache and re-authenticates once without user intervention
(`_refresh_isardvdi_session` in `main.py`).

## CLI commands (`uv run python main.py <cmd>`)

| Command | Description |
|---|---|
| `login` | Authenticate (SAML + JWT) and display session info |
| `logout` | Delete both cached session files |
| `version` | Show package version |
| `list` | List desktops with state / IP / OS; `--hardware / -H` adds CPU + Memory |
| `template` | List available templates; `--category`, `--hardware`, `--filter` |
| `create <name>` | Create desktop from template; `--template`, `--description`, `--category` |
| `start <name>` | Start a stopped desktop; `--wait / -w`, `--timeout` |
| `stop <name>` | Stop a running desktop; `--wait / -w`, `--timeout` |
| `delete <name>` | Move desktop to trash (recoverable); `--permanent` for irreversible deletion |
| `view <name>` | Open desktop in remote-viewer via SPICE; `--start`, `--install/--no-install` |
| `ssh <name>` | SSH into a desktop via the IsardVDI bastion |

All commands accept `--username / -u`, `--password / -p` (fall back to
`GENCAT_USERNAME` / `GENCAT_PASSWORD` env vars, then interactive prompt), and
`--force / -f` to bypass the session cache.

### `delete` command details

- Move to trash (default): `delete <name>` — recoverable via IsardVDI web interface
- Permanent deletion: `delete <name> --permanent` — irreversible deletion of desktop and all data
- `--yes / -y` — skip confirmation prompts
- Safety confirmations with different messaging for trash vs permanent deletion
- Desktop state validation (cannot delete running desktops)

### `template` command details

- List available templates for creating desktops
- `--category / -c <uuid>` — filter by template category
- `--hardware / -H` — show CPU, memory, and disk specifications
- `--filter <name>` — filter templates by name pattern

### `create` command details

- Create a new desktop from an available template
- `--template / -t <name>` — specify template (interactive selection if omitted)
- `--description / -d <desc>` — add description to desktop
- `--category / -c <uuid>` — filter templates by category during selection
- `--filter <name>` — filter templates by name during interactive selection
- Desktop name validation (minimum 4 characters, valid characters only)
- Username normalization (auto-appends `@edu.gencat.cat` if no domain provided)

### `view` command details

- Open a desktop in remote-viewer using SPICE protocol connection
- `--start` — auto-start the desktop if it is not already running
- `--install / --no-install` — auto-install remote-viewer if missing (default: enabled)
- Downloads SPICE connection file from `/api/v3/desktop/{id}/viewer/file-spice`
- Cross-platform virt-viewer installation:
  - Windows: Downloads MSI from GitLab releases and auto-installs
  - Fedora: Uses `dnf install virt-viewer` with sudo handling
  - Ubuntu/Debian: Uses `apt install virt-viewer` with sudo handling
  - Other platforms: Saves SPICE file and shows manual installation instructions

### `ssh` command details

- Resolves the SSH private key: `--identity / -i`, else `~/.ssh/id_ed25519`,
  else `~/.ssh/id_rsa`, else generates a new ed25519 key automatically.
- `--start` — auto-start the desktop if it is not already running.
- `--user / -l` — Linux username inside the VM (default `user`).
- `--dry-run` — print the SSH command without executing it.
- Registers the public key in the bastion `authorized_keys` (idempotent), then
  calls `os.execvp` to replace the process with the SSH session.

### Desktop name resolution (`_resolve_desktop`)

Matching priority (first hit wins):
1. Exact match (case-insensitive).
2. Best fuzzy match (`difflib.get_close_matches`, cutoff 0.5).
3. First substring match (case-insensitive).

## Public library API (`from isard import …`)

### Auth
- `login_to_gencat(username, password)` → `AuthSession`

### Desktops
- `get_desktops(session, name=None)` → `list[Desktop]`
- `start_desktop(session, desktop_id)` → `str` (new state)
- `stop_desktop(session, desktop_id)` → `str` (new state)
- `delete_desktop(session, desktop_id)` → `None` (move to trash)
- `delete_desktop_permanent(session, desktop_id)` → `None` (irreversible)
- `wait_desktop(session, desktop_id, target_state, timeout, poll_interval)` → `str`
- `star_desktop(session, desktop_id, starred=True)` — library only, no CLI command

### Templates
- `get_templates(session, category_id=None)` → `list[Template]`
- `create_desktop(session, template_id, name, description=None)` → `Desktop`

### Viewer
- `get_spice_file(session, desktop_id)` → `str` (SPICE file content)

### Installer (Cross-platform)
- `install_virt_viewer(force=False, silent=True)` — installs virt-viewer package (includes remote-viewer)
- `check_virt_viewer_status()` → `dict` (installation status info)
- `launch_virt_viewer(spice_file, desktop_name="")` — launches remote-viewer with SPICE file

### Bastion (SSH jump-host)
- `get_bastion(session, desktop_id)` → `BastionTarget`
- `set_bastion(session, desktop_id, *, ssh_enabled, ssh_port, authorized_keys, http_enabled)` → `BastionTarget`
- `add_ssh_key(session, desktop_id, public_key)` → `BastionTarget` (idempotent)

### `IsardVdiClient` methods of note
- `IsardVdiClient.login(username, password)` — class method, returns a client
- `client.is_bastion_allowed()` — checks `GET /api/v3/user/bastion/allowed`
- `client.enrich_hardware(desktops)` — fetches templates, fills `vcpus` / `memory_mb`
- `client.ssh_command(target, ssh_user)` — returns `argv` list for `os.execvp`
- `client.get_templates(category_id=None)` — fetches available templates
- `client.create_desktop(template_id, name, description)` — creates new desktop
- `client.get_spice_file(desktop_id)` — retrieves SPICE connection file
- `client.bastion_host` — hostname of the IsardVDI instance
- `client.bastion_port` — always `443` (IsardVDI default)

## IsardVDI SSH bastion — key facts

- **Bastion port is 443**, not 22.  The IsardVDI source falls back to
  `HTTPS_PORT` (443) when `BASTION_SSH_PORT` is unset.
- **SSH username** for the bastion is `BastionTarget.id` (a UUID), not the
  user's account name.
- **Connection format:**
  ```
  ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
      <target.id>@<isardvdi_host> -p 443 -i <keyfile>
  ```
- The bastion host is the same hostname as the IsardVDI web UI.
- `GET /api/v3/desktop/bastion/{id}` returns the per-desktop config.
- `PUT /api/v3/desktop/bastion/{id}` does a **full replacement** (not a patch);
  `set_bastion` fetches first then merges.

## Error hierarchy (`isard.errors`)

```
IsardError
├── NetworkError
├── HttpError                 (has .status_code)
│   └── BookingRequiredError  (HTTP 428 — booking required to start desktop)
├── WafBlockedError
├── RateLimitError
├── AzureAuthError
├── AzureFormParsingError
├── AzureRedirectError
├── SamlValidationError
├── SamlAssertionError
├── SamlParseError
├── SessionExpiredError
└── TooManyRedirectsError
```

## Running tests

```bash
# Unit tests only (no credentials needed)
uv run pytest tests/test_auth_helpers.py tests/test_saml.py -v

# All tests including integration (requires .env with GENCAT_USERNAME / GENCAT_PASSWORD)
uv run pytest -v -s

# Integration tests only
uv run pytest tests/test_integration.py -v -s
```

Integration tests are guarded with `@pytest.mark.skipif(not _CREDS_AVAILABLE, …)`
and share a single session-scoped `isardvdi_session` fixture to avoid repeated
logins.

## Key conventions

- All new IsardVDI API wrappers go in `src/isard/isardvdi.py` as methods on
  `IsardVdiClient`, then re-exported from `src/isard/__init__.py`.
- New dataclasses go in `src/isard/types.py`.
- New error types go in `src/isard/errors.py` and are exported from `__init__.py`.
- The `.env` file must never be committed.  `.idea/` and `.env` are in
  `.gitignore`.
- Desktop hardware (CPU/memory) is **not** in the desktops API response; it
  must be fetched from `GET /api/v3/template/{template_id}` via
  `enrich_hardware`.  Some desktops have `template: null` — show `-` for those.
