peregrine/docs/developer-guide/adding-integrations.md
pyr0ball 8cb636dabe docs: mkdocs wiki — installation, user guide, developer guide, reference
Adds a full MkDocs documentation site under docs/ with Material theme.

Getting Started: installation walkthrough, 7-step first-run wizard guide,
Docker Compose profile reference with GPU memory guidance and preflight.py
description.

User Guide: job discovery (search profiles, custom boards, enrichment),
job review (sorting, match scores, batch actions), apply workspace (cover
letter gen, PDF export, mark applied), interviews (kanban stages, company
research auto-trigger, survey assistant), email sync (IMAP, Gmail App
Password, classification labels, stage auto-updates), integrations (all 13
drivers with tier requirements), settings (every tab documented).

Developer Guide: contributing (dev env setup, code style, branch naming, PR
checklist), architecture (ASCII layer diagram, design decisions), adding
scrapers (full scrape() interface, registration, search profile config,
test patterns), adding integrations (IntegrationBase full interface, auto-
discovery, tier gating, test patterns), testing (patterns, fixtures, what
not to test).

Reference: tier system (full FEATURES table, can_use/tier_label API, dev
override, adding gates), LLM router (backend types, complete() signature,
fallback chains, vision routing, __auto__ resolution, adding backends),
config files (every file with field-level docs and gitignore status).

Also adds CONTRIBUTING.md at repo root pointing to the docs site.
2026-02-25 12:05:49 -08:00

249 lines
8.6 KiB
Markdown

# Adding an Integration
Peregrine's integration system is auto-discovered — add a class and a config example, and it appears in the wizard and Settings automatically. No registration step is needed.
---
## Step 1 — Create the integration module
Create `scripts/integrations/myservice.py`:
```python
# scripts/integrations/myservice.py
from scripts.integrations.base import IntegrationBase
class MyServiceIntegration(IntegrationBase):
name = "myservice" # must be unique; matches config filename
label = "My Service" # display name shown in the UI
tier = "free" # "free" | "paid" | "premium"
def fields(self) -> list[dict]:
"""Return form field definitions for the connection card in the wizard/Settings UI."""
return [
{
"key": "api_key",
"label": "API Key",
"type": "password", # "text" | "password" | "url" | "checkbox"
"placeholder": "sk-...",
"required": True,
"help": "Get your key at myservice.com/settings/api",
},
{
"key": "workspace_id",
"label": "Workspace ID",
"type": "text",
"placeholder": "ws_abc123",
"required": True,
"help": "Found in your workspace URL",
},
]
def connect(self, config: dict) -> bool:
"""
Store credentials in memory. Return True if all required fields are present.
Does NOT verify credentials — call test() for that.
"""
self._api_key = config.get("api_key", "").strip()
self._workspace_id = config.get("workspace_id", "").strip()
return bool(self._api_key and self._workspace_id)
def test(self) -> bool:
"""
Verify the stored credentials actually work.
Returns True on success, False on any failure.
"""
try:
import requests
r = requests.get(
"https://api.myservice.com/v1/ping",
headers={"Authorization": f"Bearer {self._api_key}"},
params={"workspace": self._workspace_id},
timeout=5,
)
return r.ok
except Exception:
return False
def sync(self, jobs: list[dict]) -> int:
"""
Optional: push jobs to the external service.
Return the count of successfully synced jobs.
The default implementation in IntegrationBase returns 0 (no-op).
Only override this if your integration supports job syncing
(e.g. Notion, Airtable, Google Sheets).
"""
synced = 0
for job in jobs:
try:
self._push_job(job)
synced += 1
except Exception as e:
print(f"[myservice] sync error for job {job.get('id')}: {e}")
return synced
def _push_job(self, job: dict) -> None:
import requests
requests.post(
"https://api.myservice.com/v1/records",
headers={"Authorization": f"Bearer {self._api_key}"},
json={
"workspace": self._workspace_id,
"title": job.get("title", ""),
"company": job.get("company", ""),
"status": job.get("status", "pending"),
"url": job.get("url", ""),
},
timeout=10,
).raise_for_status()
```
---
## Step 2 — Create the config example file
Create `config/integrations/myservice.yaml.example`:
```yaml
# config/integrations/myservice.yaml.example
# Copy to config/integrations/myservice.yaml and fill in your credentials.
# This file is gitignored — never commit the live credentials.
api_key: ""
workspace_id: ""
```
The live credentials file (`config/integrations/myservice.yaml`) is gitignored automatically via the `config/integrations/` entry in `.gitignore`.
---
## Step 3 — Auto-discovery
No registration step is needed. The integration registry (`scripts/integrations/__init__.py`) imports all `.py` files in the `integrations/` directory and discovers subclasses of `IntegrationBase` automatically.
On next startup, `myservice` will appear in:
- The first-run wizard Step 7 (Integrations)
- **Settings → Integrations** with a connection card rendered from `fields()`
---
## Step 4 — Tier-gate new features (optional)
If you want to gate a specific action (not just the integration itself) behind a tier, add an entry to `app/wizard/tiers.py`:
```python
FEATURES: dict[str, str] = {
# ...existing entries...
"myservice_sync": "paid", # or "free" | "premium"
}
```
Then guard the action in the relevant UI page:
```python
from app.wizard.tiers import can_use
from scripts.user_profile import UserProfile
user = UserProfile()
if can_use(user.tier, "myservice_sync"):
# show the sync button
else:
st.info("MyService sync requires a Paid plan.")
```
---
## Step 5 — Write a test
Create or add to `tests/test_integrations.py`:
```python
# tests/test_integrations.py (add to existing file)
import pytest
from unittest.mock import patch, MagicMock
from pathlib import Path
from scripts.integrations.myservice import MyServiceIntegration
def test_fields_returns_required_keys():
integration = MyServiceIntegration()
fields = integration.fields()
assert len(fields) >= 1
for field in fields:
assert "key" in field
assert "label" in field
assert "type" in field
assert "required" in field
def test_connect_returns_true_with_valid_config():
integration = MyServiceIntegration()
result = integration.connect({"api_key": "sk-abc", "workspace_id": "ws-123"})
assert result is True
def test_connect_returns_false_with_missing_required_field():
integration = MyServiceIntegration()
result = integration.connect({"api_key": "", "workspace_id": "ws-123"})
assert result is False
def test_test_returns_true_on_200(tmp_path):
integration = MyServiceIntegration()
integration.connect({"api_key": "sk-abc", "workspace_id": "ws-123"})
mock_resp = MagicMock()
mock_resp.ok = True
with patch("scripts.integrations.myservice.requests.get", return_value=mock_resp):
assert integration.test() is True
def test_test_returns_false_on_error(tmp_path):
integration = MyServiceIntegration()
integration.connect({"api_key": "sk-abc", "workspace_id": "ws-123"})
with patch("scripts.integrations.myservice.requests.get", side_effect=Exception("timeout")):
assert integration.test() is False
def test_is_configured_reflects_file_presence(tmp_path):
config_dir = tmp_path / "config"
config_dir.mkdir()
(config_dir / "integrations").mkdir()
assert MyServiceIntegration.is_configured(config_dir) is False
(config_dir / "integrations" / "myservice.yaml").write_text("api_key: sk-abc\n")
assert MyServiceIntegration.is_configured(config_dir) is True
```
---
## IntegrationBase Reference
All integrations inherit from `scripts/integrations/base.py`. Here is the full interface:
| Method / attribute | Required | Description |
|-------------------|----------|-------------|
| `name: str` | Yes | Machine key — must be unique. Matches the YAML config filename. |
| `label: str` | Yes | Human-readable display name for the UI. |
| `tier: str` | Yes | Minimum tier: `"free"`, `"paid"`, or `"premium"`. |
| `fields() -> list[dict]` | Yes | Returns form field definitions. Each dict: `key`, `label`, `type`, `placeholder`, `required`, `help`. |
| `connect(config: dict) -> bool` | Yes | Stores credentials in memory. Returns `True` if required fields are present. Does NOT verify credentials. |
| `test() -> bool` | Yes | Makes a real network call to verify stored credentials. Returns `True` on success. |
| `sync(jobs: list[dict]) -> int` | No | Pushes jobs to the external service. Returns count synced. Default is a no-op returning 0. |
| `config_path(config_dir: Path) -> Path` | Inherited | Returns `config_dir / "integrations" / f"{name}.yaml"`. |
| `is_configured(config_dir: Path) -> bool` | Inherited | Returns `True` if the config YAML file exists. |
| `save_config(config: dict, config_dir: Path)` | Inherited | Writes config dict to the YAML file. Call after `test()` returns `True`. |
| `load_config(config_dir: Path) -> dict` | Inherited | Loads and returns the YAML config, or `{}` if not configured. |
### Field type values
| `type` value | UI widget rendered |
|-------------|-------------------|
| `"text"` | Plain text input |
| `"password"` | Password input (masked) |
| `"url"` | URL input |
| `"checkbox"` | Boolean checkbox |