* 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.
5.0 KiB
freehire.dev API reference
The endpoints, parameters, and response shapes this skill depends on. This is the
file to update if the freehire API changes. Base URL defaults to
https://freehire.dev and is overridable via the FREEHIRE_API_URL env var.
Authentication
None for reads. GET /api/v1/jobs/* and /companies/* are public; only per-user
tracking mutations (apply/save/me) require a bearer API key, and this skill
does not use them.
Verified against the live API:
| Endpoint | Status |
|---|---|
GET /api/v1/jobs/search |
200 |
GET /api/v1/jobs/facets |
200 |
GET /api/v1/jobs/{slug} |
200 |
GET /api/v1/auth/me |
401 (auth required — not used here) |
Envelope
Every response is { "data": ..., "meta": {...}, "error": "..." }. Lists put the
array in data and pagination in meta ({ total, limit, offset }); a single
item puts the object in data. Errors are { "error": "<message>" } with a 4xx/5xx
status (e.g. 404 → { "error": "not found" }).
GET /api/v1/jobs/search
Full-text + facet search over open jobs. Returns data: [job, …] with
meta.total = the total match count.
Query parameters used by the skill:
| Param | Maps to CLI flag | Notes |
|---|---|---|
q |
--query / -q |
Keyword full-text query. |
limit |
--limit / -n |
Page size. Default 25 in the CLI. |
offset |
(derived) | offset = (page - 1) * limit. |
semantic_ratio |
(fixed 0) |
Keyword search; the semantic index is opt-in. |
posted_within_days |
--jobage |
Restrict to postings from the last N days. |
regions |
--region |
Repeatable; OR within the facet. Values like global, eu, us, apac, latam, cis. |
countries |
--country |
Repeatable; ISO-3166 alpha-2 (lowercased). |
cities |
--city |
Repeatable; display-name city. |
seniority |
--seniority |
Repeatable; junior, middle, senior, staff, … |
category |
--category |
Repeatable; backend, frontend, fullstack, devops, ml_ai, … |
skills |
--skill |
Repeatable; canonical skill names. |
company_slug |
--company |
Single company. |
work_mode |
--remote |
remote | hybrid | onsite. |
| any facet param | --facet key=value |
Escape hatch for the long tail (e.g. salary_min, visa_sponsorship, employment_type, english_level). |
Repeated params (?seniority=senior&seniority=staff) are ORed within a facet;
different facets are ANDed (geography ORs into one location group). Deep paging is
bounded server-side (offset + limit ≤ 10000).
Job object (the fields the skill reads)
{
"public_slug": "golang-zensar-2bxu6dxm", // -> result.id, and detail's <slug>
"source": "oracle",
"external_id": "…",
"url": "https://…", // the real posting URL (ATS host)
"title": "GOLANG",
"company": "Zensar",
"company_slug": "zensar",
"location": "India", // free-text ATS location
"description": "<ul><li>…</li></ul>", // HTML; the skill strips it for detail
"skills": ["go", "kubernetes", …], // dictionary facet (top-level)
"work_mode": "remote", // may be absent
"regions": ["apac"], // dictionary/hybrid facet
"countries": ["in"],
"cities": [],
"collections": [],
"posted_at": "2026-07-06T00:00:00Z", // -> result.date (nullable)
"created_at": "2026-07-06T15:25:…Z",
"enrichment": { // nested, typed; {} when unenriched
"seniority": "senior",
"category": "backend",
"employment_type": "full_time",
"salary_min": 90000, "salary_max": 120000, "salary_currency": "EUR"
}
}
The internal numeric id is deliberately never exposed; public_slug is the stable
identifier.
GET /api/v1/jobs/{slug}
A single job by its public_slug. Returns the same job object in data. A closed
posting is still served here (with a non-null closed_at); a missing slug is 404
{ "error": "not found" }. The skill's detail command maps a 404 to a
NOT_FOUND error on stderr.
GET /api/v1/jobs/facets
The market's facet-value distributions under an optional filter — each facet's live
values with counts. data.facets is { <facet>: { <value>: <count> } }. This skill
does not call it programmatically, but it is the vocabulary source the SKILL.md
points users to (?q=<role> scopes the counts). Example:
GET /api/v1/jobs/facets?q=react.
Parsing notes
- The response is JSON, so there is no HTML card parsing (unlike the scraping
portals). The only markup handling is stripping the
description's HTML into readable text (cleanHtmlincli/src/helpers.ts). - Fetch uses a browser-ish User-Agent,
Accept: application/json, and exponential backoff with jitter on 429/5xx (max 6 retries). A connection error (API unreachable) fails fast with a clear message — no retry, since it is not transient server load — which is the graceful-degradation contract: an outage degrades this source quickly instead of hanging the caller.