Add freehire-search: country-agnostic freehire.dev aggregator skill (#85)

* feat(freehire-search): add country-agnostic freehire.dev aggregator skill

Adds a portal-search skill over the freehire.dev public JSON API — an
open-source IT job aggregator normalizing ~50 ATS platforms across many
markets into one schema. Like linkedin-search it is country-agnostic and
zero-dependency (plain bun + fetch), but it queries a JSON API rather than
scraping HTML, so results carry structured facets (skills/seniority/region).

Honors the portal-skill contract: search + detail commands, --format
json|table|plain, stderr JSON errors with exit 1, backoff on 429/5xx. Reads
are public (no API key) — the same zero-signup bar as linkedin-search. The
hosted-service dependency (best-effort, no SLA) is labeled prominently in
SKILL.md, and FREEHIRE_API_URL swaps the base URL for a self-hosted backend.

Scoped tech-first: triggers cover software/data/engineering roles, where the
faceted filtering is strong; non-tech coverage exists but is still maturing.

Network-free tests (mocked fetch + pure reshape/parse functions); CI matrix
updated to typecheck the new CLI.

* refactor(freehire-search): clarity pass on cli flag parsing

No behavior change. Replace a nested ternary and a comma-operator side effect
in a ternary with explicit if/else, and fix a comment that described facets
while sitting on the alias map.

* refactor(freehire-search): tighten to boundary contracts, trim comments

- Validate/normalize at boundaries, trust the declared types inside: drop the
  redundant '?? []' guards on facet arrays the wire contract already guarantees,
  and the re-filter in buildQuery (commaList already stripped empties).
- Model enrichment as always-present (an unenriched job serializes it as {}),
  removing the '?? {}' guard.
- Replace the positional table-row builder with a declarative column list; add a
  shared shortDate and a labeled-field helper for detail's plain output.
- Extract stringFlag for the string-or-bare-boolean flags (--remote/--query/...).
- Dedup the response parse in apiGet to a single tolerant read (drop safeJson).
- SKILL.md: document partial data + the 'none' unspecified-region facet.
- Trim restating comments to the reference skills' density.
This commit is contained in:
Ilya Strelov
2026-07-09 06:04:35 +02:00
committed by GitHub
parent e16afac7b9
commit b8d35a4b69
14 changed files with 1354 additions and 0 deletions
@@ -0,0 +1,87 @@
# freehire-cli
CLI for searching the [freehire.dev](https://freehire.dev) job aggregator across
**many markets** (tech-focused), via its public JSON API.
**Data source**: freehire.dev REST API (`/api/v1/jobs/search`, `/api/v1/jobs/facets`, `/api/v1/jobs/{slug}`).
**Authentication**: None required — reads are public (only tracking mutations need a key, and those are out of scope here).
**Dependencies**: None (plain `bun` + `fetch`). `bun install` is optional and only pulls dev type defs.
> **Hosted-service dependency.** This skill talks to freehire.dev, a personal
> project maintained on a **best-effort basis with no formal SLA**. If the API is
> unreachable the CLI exits non-zero with a clear error rather than hanging, so an
> outage degrades gracefully instead of breaking the caller. Point `FREEHIRE_API_URL`
> at a self-hosted [freehire](https://github.com/strelov1/freehire) backend to swap
> the source.
## Installation
```bash
cd .agents/skills/freehire-search/cli
bun install # optional — only installs TypeScript dev types
```
The CLI runs without any install because it has zero runtime dependencies.
## Self-hosting / base URL
The base URL defaults to `https://freehire.dev` and is overridable with an env var:
```bash
FREEHIRE_API_URL=http://localhost:8080 bun run src/cli.ts search -q "go"
```
The freehire backend is MIT-licensed and stands up with one command via Docker
Compose (`make up` → API on `:8080`, same `/api/v1/...` paths).
## Commands
| Command | Description |
|---------|-------------|
| `search` | Search jobs by keyword and facet filters |
| `detail` | Fetch full detail for a single job by its slug |
`search` accepts `--format json|table|plain` (default `json`); `detail` accepts `--format json|plain`.
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` with exit code `1`.
## Quick examples
```bash
# Senior backend roles, table view
bun run src/cli.ts search -q "backend engineer" --seniority senior --limit 10 --format table
# Remote React roles in the EU
bun run src/cli.ts search -q "react" --remote remote --region eu --format table
# DevOps roles in Germany posted in the last 14 days
bun run src/cli.ts search --category devops --country DE --jobage 14 --format table
# Full detail for one job (slug from a search result's id)
bun run src/cli.ts detail golang-zensar-2bxu6dxm --format plain
```
See `../SKILL.md` for the full flag reference and the hosted-dependency note.
## Search flags
| Flag | Alias | Description |
|------|-------|-------------|
| `--query` | `-q` | Keywords (title / skill / role). Full-text; optional. |
| `--jobage` | | Posted within N days (`posted_within_days`). |
| `--page` | | 1-indexed page. Default 1. |
| `--limit` | `-n` | Results per page (API limit). Default 25. |
| `--region` | | Macro-region(s), comma = OR (e.g. `eu,us`). |
| `--country` | | ISO-3166 alpha-2 code(s). |
| `--city` | | City name(s). |
| `--seniority` | | Seniority level(s). |
| `--category` | | Role category(ies). |
| `--skill` | | Canonical skill(s). |
| `--company` | | Company slug. |
| `--remote` | | `remote` \| `hybrid` \| `onsite` (`work_mode`). |
| `--facet` | | Any other facet as `key=value` (repeatable). |
| `--format` | | `json` \| `table` \| `plain`. |
Facet values come from freehire's controlled vocabularies. Discover the live
values (with counts) for a market at
[`/api/v1/jobs/facets`](https://freehire.dev/api/v1/jobs/facets), or narrow it,
e.g. `https://freehire.dev/api/v1/jobs/facets?q=react`.