Files
santifer--career-ops/plugins/README.md
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

118 lines
5.5 KiB
Markdown

# career-ops plugins
The plugin layer is the **opt-in home for integrations that need a key or talk
to an external service** — things the zero-keys, local-first core deliberately
doesn't carry. It generalizes the proven `providers/` pattern: drop a directory
in here, declare a manifest, and it's discovered automatically.
> **Not the Claude Code plugin.** This is unrelated to `.claude-plugin/` (the
> Claude Code marketplace metadata). These plugins extend career-ops itself.
## Default: off
Plugins load **only** when you opt in. With no `config/plugins.yml`, the core
runs exactly as it always has — no plugin code runs, no `.env` is read, nothing
changes. Two gates must both be satisfied:
1. **Enable** the plugin in `config/plugins.yml` (copy `config/plugins.example.yml`).
2. **Provide its keys** in your own `.env` (each plugin declares which it needs).
Run `node doctor.mjs` or `node plugins.mjs list` to see what's missing.
## Anatomy of a plugin
A plugin is a directory under `plugins/` (bundled, shipped with career-ops) or
`plugins.local/` (your own, gitignored, never auto-updated):
```
plugins/<id>/
manifest.json # parsed, not executed — validated before any code is imported
index.mjs # default-exports an object keyed by hook type
_anything.mjs # helpers (the _ prefix means "never discovered as a plugin")
```
### manifest.json
```json
{
"id": "wellfound", // must equal the directory name; [a-z0-9-]
"apiVersion": 1,
"description": "One mission-framed line.",
"hooks": ["provider"], // any of: provider, ingest, search, notify, export
"requiredEnv": ["WELLFOUND_TOKEN"],// env VAR NAMES only — values go in .env
"allowedHosts": ["api.wellfound.com"], // required when requiredEnv is non-empty
"humanInTheLoop": true // must be true
}
```
### Hooks (`index.mjs` default export)
| Hook | Signature | Does |
|------|-----------|------|
| `provider` | `{ id, detect?, fetch(entry, ctx) → Job[] }` | A keyed/auth-gated job source. Same shape as `providers/_types.js`. Runs via `scan` on a `provider: <id>` entry in `portals.yml`. |
| `ingest` | `(ctx) → Job[]` | Pull postings from a service (email, a board). |
| `search` | `(query, ctx) → Job[]` | Postings for a query string. |
| `export` | `(snapshot, ctx) → {pushed}` | Push a **read-only** tracker snapshot to your own external store. |
| `notify` | `(payload, ctx) → void` | Send an outbound notification. |
Producers (`provider`/`ingest`/`search`) **return** `Job[]`
(`{title, url, company, location}`); the engine — never the plugin — writes them
to `data/pipeline.md` through the canonical writer, so a plugin can't break the
data formats the web reads. Non-provider hooks run explicitly:
```bash
node plugins.mjs list
node plugins.mjs run gmail # ingest
node plugins.mjs run notion search "platform" # search
node plugins.mjs run notion export [--dry-run] # export
```
### The `ctx` object
- `fetch(url, opts)` — the **guarded** primitive: HTTPS-only, pinned to your
`allowedHosts`, `redirect:'manual'` re-validating every hop and stripping
credentials on a hostname change. **Route your HTTP through `ctx.fetch`** (or
the `fetchText`/`fetchJson` conveniences over it) so the egress guard actually
runs — a plugin that calls global `fetch` bypasses it (the bundled `apify`
plugin is one deliberate exception: its client self-constrains to a single
hardcoded host, documented in its code).
- `env` (frozen, scoped to your declared keys), `settings` (your non-secret
`config/plugins.yml` block), `log` (redacts your declared secrets), `dryRun`.
## Your own plugins → `plugins.local/`
Put private or experimental plugins in **`plugins.local/`** (a sibling of
`plugins/`), never in `plugins/`. `plugins.local/` is gitignored and never
auto-updated, so updates can't clobber it and a same-id bundled plugin can't be
shadowed by it. Bundled plugins always win an id collision.
## Trust model (read this)
career-ops is plain ESM with no build step, so the engine **cannot truly
sandbox** a plugin's imports. `allowedHosts`, the scoped `ctx.env`, and the
no-auto-submit hook taxonomy constrain an **honest** plugin and make every loaded
plugin visible (`doctor` / `plugins.mjs list`) — but they are not a hard
boundary against malicious code, which can reach `process.env` or the network
directly. Containment is the same as everywhere else in open source:
- **Bundled plugins** (`plugins/`) are code-reviewed exactly like `providers/`.
CI checks that they declare no core-owned secret, import no browser-automation
or process-spawning module, and never auto-submit.
- **`plugins.local/`** runs with **your** trust — you installed it. Treat a
third-party plugin like any code you run on your machine.
## Not a plugin
These don't belong in the plugin layer — they're a different direction:
- **Centralized infrastructure** the project would operate — hosted job
aggregation, a shared matching service, proxies/Workers. That's a **separate,
opt-in service**, discussed in
[Where career-ops is going (#904)](https://github.com/santifer/career-ops/discussions/904) —
not the open-core.
- **Auto-submitting / blind-applying** to jobs. career-ops is a decision-support
tool, not a spam bot — it drafts applications for **you** to review and submit.
No hook can submit, and `humanInTheLoop: true` is mandatory. This holds
everywhere, in core and plugins alike.
See `CONTRIBUTING.md` → "Scope" for the full boundary.