From b8d35a4b699386cf2682981f6abf7ed2d5d43f01 Mon Sep 17 00:00:00 2001 From: Ilya Strelov Date: Thu, 9 Jul 2026 01:04:35 -0300 Subject: [PATCH] Add freehire-search: country-agnostic freehire.dev aggregator skill (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .agents/skills/freehire-search/SKILL.md | 173 ++++++++++++++ .agents/skills/freehire-search/cli/README.md | 87 +++++++ .../skills/freehire-search/cli/package.json | 20 ++ .agents/skills/freehire-search/cli/src/cli.ts | 189 +++++++++++++++ .../cli/src/commands/detail.ts | 65 +++++ .../cli/src/commands/search.ts | 117 +++++++++ .../skills/freehire-search/cli/src/helpers.ts | 223 ++++++++++++++++++ .../cli/tests/cli-flag-validation.test.ts | 69 ++++++ .../cli/tests/commands.test.ts | 140 +++++++++++ .../freehire-search/cli/tests/helpers.ts | 40 ++++ .../freehire-search/cli/tests/parsing.test.ts | 102 ++++++++ .../skills/freehire-search/cli/tsconfig.json | 14 ++ .../skills/freehire-search/url-reference.md | 114 +++++++++ .github/workflows/ci.yml | 1 + 14 files changed, 1354 insertions(+) create mode 100644 .agents/skills/freehire-search/SKILL.md create mode 100644 .agents/skills/freehire-search/cli/README.md create mode 100644 .agents/skills/freehire-search/cli/package.json create mode 100644 .agents/skills/freehire-search/cli/src/cli.ts create mode 100644 .agents/skills/freehire-search/cli/src/commands/detail.ts create mode 100644 .agents/skills/freehire-search/cli/src/commands/search.ts create mode 100644 .agents/skills/freehire-search/cli/src/helpers.ts create mode 100644 .agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts create mode 100644 .agents/skills/freehire-search/cli/tests/commands.test.ts create mode 100644 .agents/skills/freehire-search/cli/tests/helpers.ts create mode 100644 .agents/skills/freehire-search/cli/tests/parsing.test.ts create mode 100644 .agents/skills/freehire-search/cli/tsconfig.json create mode 100644 .agents/skills/freehire-search/url-reference.md diff --git a/.agents/skills/freehire-search/SKILL.md b/.agents/skills/freehire-search/SKILL.md new file mode 100644 index 0000000..28f3fe3 --- /dev/null +++ b/.agents/skills/freehire-search/SKILL.md @@ -0,0 +1,173 @@ +--- +name: freehire-search +version: 1.0.0 +description: > + Use this skill to search live software / tech / data / engineering job listings + across many countries and markets (and remote) via the freehire.dev aggregator's + public API, or to look up a specific posting. It aggregates roles from ~50 ATS + platforms into one schema, so a single skill covers many markets — but its faceted + filtering (skills, category, seniority) is tuned tech-first, so scope triggers to + technical roles. Trigger phrases: find a tech job, software job search, developer + jobs, engineering vacancies, data/ML jobs, DevOps roles, remote developer jobs, + "are there any jobs in ", look up this freehire job posting. +context: fork +allowed-tools: Bash(bun run .agents/skills/freehire-search/cli/src/cli.ts *) +--- + +# freehire Search Skill + +Search live job listings from the **[freehire.dev](https://freehire.dev)** job +aggregator — an open-source IT job board that normalizes postings from ~50 ATS +platforms across many countries into one schema. No authentication, no API key, +and **zero runtime dependencies** — it runs with just `bun`. The market is chosen +per query via facet flags (`--region`, `--country`), so the same skill works for a +forker in any market out of the box. + +> This is a country-agnostic worked example of the repo's job-portal-skill pattern, +> like `linkedin-search`. Unlike the HTML-scraping portals, it queries freehire's +> public JSON API, so results are structured (skills, seniority, region facets) +> rather than parsed from markup. + +## ⚠️ Scope: tech-focused + +freehire's corpus already includes some non-tech postings (it crawls whole company +career pages), **but its faceted filtering — skills, categories, and seniority +dictionaries — is tuned tech-first today**, so this skill scopes its triggers to +software / data / engineering / tech roles, where the filtering is strong. Non-tech +coverage exists but is still maturing; don't rely on this skill for general +(non-technical) job coverage yet. + +## ℹ️ Hosted-service dependency (best-effort, no SLA) + +This skill depends on a third-party hosted service, freehire.dev. Reads are +**public and unauthenticated** — the same zero-signup bar as `linkedin-search`. + +**freehire.dev is a personal project but actively maintained; it runs on a +best-effort basis (no formal SLA).** If the API is unreachable, the CLI fails +gracefully — a non-zero exit with a clear error message — so an outage degrades +this source rather than breaking the surrounding workflow. + +**Self-hosting / swappable base URL.** The freehire backend is a separate +MIT-licensed repo — [`strelov1/freehire`](https://github.com/strelov1/freehire) +(Go + PostgreSQL + Meilisearch) — that stands up with one command via Docker +Compose (`make up` → API on `:8080`, same `/api/v1/...` paths). The skill honors a +base-URL env var, `FREEHIRE_API_URL` (default `https://freehire.dev`), so pointing +it at a local instance is a one-line change: + +```bash +FREEHIRE_API_URL=http://localhost:8080 bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "go" +``` + +Caveat: standing up the *API* is light, but keeping a *full, continuously-fresh* +mirror (millions of postings across ~50 platforms) is resource-heavy — a +self-hoster would either crawl a scoped subset of sources or point the env var back +at the hosted API. + +## When to use this skill + +- Search for tech job openings by keyword, in a given region/country or remotely +- Filter by seniority, category, skills, or recency (posted within N days) +- Get the full description of a specific freehire posting by its slug + +## Commands + +### Search job listings + +```bash +bun run .agents/skills/freehire-search/cli/src/cli.ts search [-q ""] [facet flags] +``` + +Key flags: +- `--query ` / `-q ` — keyword search (title, skill, role). Full-text; optional. +- `--jobage ` — posted within N days (maps to `posted_within_days`). +- `--page ` — 1-indexed page. Default 1. +- `--limit ` / `-n ` — results per page (API limit). Default 25. +- `--format json|table|plain` — default `json`. + +Facet filters (values come from freehire's controlled vocabularies; comma-separate for OR within a facet): +- `--region ` — macro-region, e.g. `global`, `eu`, `us`, `apac`, `latam`, `cis`. `--region eu,us`. Use `none` to match jobs whose region could **not** be resolved (see "Partial data" below). +- `--country ` — ISO-3166 alpha-2, e.g. `--country DE,GB` +- `--city ` — city name(s), e.g. `--city Berlin` +- `--seniority ` — `junior`, `middle`, `senior`, `staff`, `principal`, `lead`, … +- `--category ` — `backend`, `frontend`, `fullstack`, `devops`, `ml_ai`, `qa`, … +- `--skill ` — canonical skill(s), e.g. `--skill go,kubernetes` +- `--company ` — company slug (from a result's `company_slug`) +- `--remote ` — `remote` | `hybrid` | `onsite` (`work_mode` facet) +- `--facet ` — any other facet param (repeatable), e.g. `--facet salary_min=100000` + +> **Location is a facet, not free text.** Unlike `linkedin-search`'s `--location`, +> freehire filters geography through the structured `--region`/`--country`/`--city` +> facets. Discover the live values for a market at +> [`/api/v1/jobs/facets`](https://freehire.dev/api/v1/jobs/facets) (append `?q=` +> to scope it) — never invent facet values. + +### Fetch full job detail + +```bash +bun run .agents/skills/freehire-search/cli/src/cli.ts detail [--format json|plain] +``` + +`slug` is the `id` from a `search` result (e.g. `golang-zensar-2bxu6dxm`). You may +also pass a full `https://freehire.dev/jobs/` URL. Returns the full (HTML-stripped) +description, skills, region/country, and — when the posting is enriched — seniority, +category, employment type, and salary. + +## Usage examples + +```bash +# Senior backend roles, table view +bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "backend engineer" --seniority senior --limit 10 --format table + +# Remote React roles in the EU +bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "react" --remote remote --region eu --format table + +# DevOps roles in Germany posted in the last 14 days +bun run .agents/skills/freehire-search/cli/src/cli.ts search --category devops --country DE --jobage 14 --format table + +# ML/AI roles anywhere, fully remote +bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "machine learning" --category ml_ai --remote remote --format table + +# Full details for a specific job +bun run .agents/skills/freehire-search/cli/src/cli.ts detail golang-zensar-2bxu6dxm --format plain +``` + +## Output formats + +| Format | Best for | +|--------|----------| +| `json` | Default — programmatic use, passing a result's `id` (slug) to `detail` | +| `table` | Quick human-readable scanning | +| `plain` | Reading a single job's full detail (`detail` command) | + +Search JSON is `{ "meta": { "count", "page", "total" }, "results": [...] }`; each +result carries at least `id` (the freehire slug), `title`, `company`, `location`, +`date`, and `url` (missing values are `null`). All errors are written to **stderr** +as `{ "error": "...", "code": "..." }` and the process exits with code `1`. + +## Partial data + +Facets are derived per-posting and can be **incomplete** — geography especially. +A job may resolve its `work_mode` (e.g. `remote`) but leave its **region or +country undetermined** when the source's location text is ambiguous (freehire's +dictionaries never guess). So: + +- A missing region/country means "not resolved", **not** "not applicable" — + filtering on `--region eu` silently drops jobs whose region wasn't resolved, + even if they are in fact EU. Widen or drop the facet if you need those back. +- There is a dedicated facet value for the unresolved bucket: `--region none` + matches jobs with **no** resolved region — useful to sweep up remote roles that + never pinned a geography. It ORs with real regions, e.g. `--region eu,none`. +- `result.regions` / `countries` / `cities` may be empty arrays for the same + reason; treat empty as unknown, not as "none of the above". + +## Notes + +- Data is from freehire.dev's public API — no credentials required. Only per-user + tracking (apply/save) needs a key, and this skill deliberately does not touch it: + it is **search + detail only**. +- `id` in search results is the freehire `public_slug` — pass it as-is to `detail`. +- `date` is the posting date (`posted_at`); it may be `null` for undated postings. +- Facet values are controlled vocabularies. Use `/api/v1/jobs/facets` to see the + live values (with counts) for a query before filtering. +- The API retries 429/5xx with exponential backoff; an unreachable API exits + non-zero with a clear message (best-effort service, see the dependency note above). diff --git a/.agents/skills/freehire-search/cli/README.md b/.agents/skills/freehire-search/cli/README.md new file mode 100644 index 0000000..82042a9 --- /dev/null +++ b/.agents/skills/freehire-search/cli/README.md @@ -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`. diff --git a/.agents/skills/freehire-search/cli/package.json b/.agents/skills/freehire-search/cli/package.json new file mode 100644 index 0000000..5e55355 --- /dev/null +++ b/.agents/skills/freehire-search/cli/package.json @@ -0,0 +1,20 @@ +{ + "name": "freehire-cli", + "version": "1.0.0", + "description": "CLI for searching the freehire.dev job aggregator's public JSON API across many markets (tech-focused) — no authentication, zero runtime dependencies. Base URL is swappable via FREEHIRE_API_URL for self-hosting.", + "type": "module", + "main": "src/cli.ts", + "bin": { + "freehire-search": "src/cli.ts" + }, + "scripts": { + "start": "bun run src/cli.ts", + "test": "bun test --timeout 30000", + "typecheck": "tsc --noEmit" + }, + "dependencies": {}, + "devDependencies": { + "typescript": "^5.4.0", + "@types/bun": "latest" + } +} diff --git a/.agents/skills/freehire-search/cli/src/cli.ts b/.agents/skills/freehire-search/cli/src/cli.ts new file mode 100644 index 0000000..e89794a --- /dev/null +++ b/.agents/skills/freehire-search/cli/src/cli.ts @@ -0,0 +1,189 @@ +#!/usr/bin/env bun +// Self-contained CLI for searching the freehire.dev aggregator's public JSON API. +// No external CLI framework and zero runtime dependencies, so it runs anywhere +// `bun` is available with nothing installed beyond the repo clone. +// +// Hosted-service dependency: reads are public (no API key), but they hit +// freehire.dev — a personal project maintained best-effort (no formal SLA). Point +// FREEHIRE_API_URL at a self-hosted freehire backend to swap the source. + +import { runSearch, type SearchOpts } from "./commands/search.js" +import { runDetail, type DetailOpts } from "./commands/detail.js" +import { baseUrl } from "./helpers.js" + +interface Flags { + _: string[] + [k: string]: string | boolean | string[] +} + +// Short-flag aliases. +const ALIAS: Record = { q: "query", n: "limit" } + +function parseFlags(argv: string[]): Flags { + const flags: Flags = { _: [] } + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (!a.startsWith("-")) { + ;(flags._ as string[]).push(a) + continue + } + const name = a.replace(/^-+/, "") + const key = ALIAS[name] ?? name + const next = argv[i + 1] + // A flag with no following value (or another flag next) is a boolean. + let value: string | boolean = true + if (next !== undefined && !next.startsWith("-")) { + value = next + i++ + } + // --facet repeats; collect into an array. Everything else is last-wins. + if (key === "facet") { + const acc = Array.isArray(flags.facet) ? flags.facet : [] + if (typeof value === "string") acc.push(value) + flags.facet = acc + } else { + flags[key] = value + } + } + return flags +} + +type FlagValue = string | boolean | string[] | undefined + +/** + * A flag's string value. A bare flag (set without a value, i.e. `true`) yields + * `whenBare` — e.g. `--remote` alone means work_mode "remote". + */ +function stringFlag(raw: FlagValue, whenBare?: string): string | undefined { + if (typeof raw === "string") return raw + if (raw === true) return whenBare + return undefined +} + +/** Split a comma-separated facet value ("eu,us") into a trimmed value list. */ +function commaList(raw: FlagValue): string[] { + if (typeof raw !== "string") return [] + return raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean) +} + +const HELP = `freehire-cli — search the freehire.dev job aggregator (many markets, tech-focused) + +USAGE + bun run src/cli.ts search [-q ""] [facet flags] [--format json|table|plain] + bun run src/cli.ts detail [--format json|plain] + +SEARCH FLAGS + --query, -q Keywords (title, skill, role). Full-text; optional. + --jobage Posted within N days (maps to posted_within_days). + --page 1-indexed page. Default 1. + --limit, -n Results per page (API limit). Default 25. + --format json (default) | table | plain. + +FACET FILTERS (values from freehire.dev's controlled vocabularies; comma = OR) + --region Macro-region: global, eu, us, apac, latam, cis, ... e.g. --region eu,us + --country ISO-3166 alpha-2, e.g. --country DE,GB + --city City name(s), e.g. --city Berlin + --seniority junior, middle, senior, staff, principal, lead, ... + --category backend, frontend, fullstack, devops, ml_ai, qa, ... + --skill Canonical skill(s), e.g. --skill go,kubernetes + --company Company slug (from a result's company_slug). + --remote remote | hybrid | onsite (work_mode facet). + --facet Any other facet param (repeatable), e.g. --facet salary_min=100000 + +DETAIL + A freehire public slug (from a search result's id/slug) + or a full https://freehire.dev/jobs/ URL. + +EXAMPLES + bun run src/cli.ts search -q "backend engineer" --seniority senior --limit 10 --format table + bun run src/cli.ts search -q "react" --remote remote --region eu --format table + bun run src/cli.ts search --category devops --country DE --jobage 14 --format table + bun run src/cli.ts detail golang-zensar-2bxu6dxm --format plain + +Reads are public (no API key). Source: ${baseUrl()} — a personal project, +best-effort, no SLA. Override with FREEHIRE_API_URL to use a self-hosted backend. +` + +function parseIntFlag(name: string, raw: string | boolean | string[]): number | null { + const val = parseInt(raw as string, 10) + if (isNaN(val)) { + process.stderr.write(JSON.stringify({ error: `--${name} must be a number, got "${raw}"`, code: "BAD_ARG" }) + "\n") + return null + } + return val +} + +async function main(): Promise { + const argv = process.argv.slice(2) + const flags = parseFlags(argv) + const cmd = (flags._ as string[])[0] + + if (!cmd || flags.help || flags.h) { + process.stdout.write(HELP) + return cmd ? 0 : 1 + } + + if (cmd === "search") { + const fmt = (flags.format as string) || "json" + + for (const name of ["jobage", "page", "limit"] as const) { + if (flags[name] !== undefined) { + const v = parseIntFlag(name, flags[name]) + if (v === null) return 1 + flags[name] = String(v) + } + } + + // Generic --facet key=value list -> param -> values. + const facets: Record = {} + const rawFacets = Array.isArray(flags.facet) ? flags.facet : [] + for (const kv of rawFacets) { + const eq = kv.indexOf("=") + if (eq <= 0) { + process.stderr.write(JSON.stringify({ error: `invalid --facet "${kv}", want key=value`, code: "BAD_ARG" }) + "\n") + return 1 + } + const key = kv.slice(0, eq) + const vals = commaList(kv.slice(eq + 1)) + facets[key] = (facets[key] ?? []).concat(vals) + } + + const opts: SearchOpts = { + query: stringFlag(flags.query), + jobage: flags.jobage ? parseInt(flags.jobage as string, 10) : 9999, + page: flags.page ? Math.max(1, parseInt(flags.page as string, 10)) : 1, + limit: flags.limit ? Math.max(1, parseInt(flags.limit as string, 10)) : 25, + format: (["json", "table", "plain"].includes(fmt) ? fmt : "json") as SearchOpts["format"], + regions: commaList(flags.region), + countries: commaList(flags.country), + cities: commaList(flags.city), + seniority: commaList(flags.seniority), + category: commaList(flags.category), + skills: commaList(flags.skill), + company: stringFlag(flags.company), + // --remote takes the given work_mode; a bare --remote means "remote". + workMode: stringFlag(flags.remote, "remote"), + facets, + } + return runSearch(opts) + } + + if (cmd === "detail") { + const id = (flags._ as string[])[1] + if (!id) { + process.stderr.write(JSON.stringify({ error: "detail requires a ", code: "NO_ID" }) + "\n") + return 1 + } + const fmt = (flags.format as string) || "json" + const opts: DetailOpts = { id, format: fmt === "plain" ? "plain" : "json" } + return runDetail(opts) + } + + process.stderr.write(JSON.stringify({ error: `Unknown command "${cmd}"`, code: "BAD_CMD" }) + "\n") + return 1 +} + +main().then((code) => process.exit(code)) diff --git a/.agents/skills/freehire-search/cli/src/commands/detail.ts b/.agents/skills/freehire-search/cli/src/commands/detail.ts new file mode 100644 index 0000000..405e0f9 --- /dev/null +++ b/.agents/skills/freehire-search/cli/src/commands/detail.ts @@ -0,0 +1,65 @@ +import { apiGet, normalizeSlug, toDetail, writeError, type FreehireJob, type JobDetailResult } from "../helpers.js" + +export interface DetailOpts { + id: string // a freehire public slug or a /jobs/ URL + format: "json" | "plain" +} + +/** A human-readable rendering of one job: header, present fields, description. */ +function renderPlain(job: JobDetailResult): string { + const lines = [job.title, `${job.company ?? "—"} · ${job.location ?? "—"}`] + + const field = (label: string, value: string | null) => { + if (value) lines.push(`${label}: ${value}`) + } + field("Posted", job.date && job.date.slice(0, 10)) + field("Seniority", job.seniority) + field("Category", job.category) + field("Employment", job.employment_type) + field("Salary", job.salary) + field("Skills", job.skills.length ? job.skills.join(", ") : null) + + lines.push("", job.description ?? "(no description)", "", `URL: ${job.url}`, `slug: ${job.id}`) + return lines.join("\n") +} + +export async function runDetail(opts: DetailOpts): Promise { + const slug = normalizeSlug(opts.id) + if (!slug) { + writeError(`could not parse a freehire slug from "${opts.id}"`, "BAD_ID") + return 1 + } + try { + const env = await apiGet(`/api/v1/jobs/${encodeURIComponent(slug)}`) + if (!env) { + writeError("job not found", "NOT_FOUND") + return 1 + } + const job = toDetail(env.data) + + if (opts.format === "plain") { + const lines = [ + job.title, + `${job.company || "—"} · ${job.location || "—"}`, + job.date ? `Posted: ${job.date.slice(0, 10)}` : "", + job.seniority ? `Seniority: ${job.seniority}` : "", + job.category ? `Category: ${job.category}` : "", + job.employment_type ? `Employment: ${job.employment_type}` : "", + job.salary ? `Salary: ${job.salary}` : "", + job.skills.length ? `Skills: ${job.skills.join(", ")}` : "", + "", + job.description || "(no description)", + "", + `URL: ${job.url}`, + `slug: ${job.id}`, + ].filter((l) => l !== "") + process.stdout.write(lines.join("\n") + "\n") + } else { + process.stdout.write(JSON.stringify(job, null, 2) + "\n") + } + return 0 + } catch (e) { + writeError(e instanceof Error ? e.message : String(e), "DETAIL_FAILED") + return 1 + } +} diff --git a/.agents/skills/freehire-search/cli/src/commands/search.ts b/.agents/skills/freehire-search/cli/src/commands/search.ts new file mode 100644 index 0000000..3de087a --- /dev/null +++ b/.agents/skills/freehire-search/cli/src/commands/search.ts @@ -0,0 +1,117 @@ +import { apiGet, toResult, writeError, type FreehireJob, type JobResult } from "../helpers.js" + +export interface SearchOpts { + query?: string + jobage: number + page: number + limit: number + format: "json" | "table" | "plain" + // Facet filters (already parsed into value lists; empty means unset). + regions: string[] + countries: string[] + cities: string[] + seniority: string[] + category: string[] + skills: string[] + company?: string + workMode?: string // work_mode facet: remote | hybrid | onsite + // Arbitrary facet escape hatch: param -> values, for the long tail of the vocabulary. + facets: Record +} + +function buildQuery(opts: SearchOpts): URLSearchParams { + const p = new URLSearchParams() + if (opts.query) p.set("q", opts.query) + p.set("limit", String(opts.limit)) + p.set("offset", String((opts.page - 1) * opts.limit)) + p.set("semantic_ratio", "0") // keyword search; the semantic index is opt-in + if (opts.jobage > 0 && opts.jobage < 9999) p.set("posted_within_days", String(opts.jobage)) + if (opts.workMode) p.set("work_mode", opts.workMode) + if (opts.company) p.set("company_slug", opts.company) + + // Named facets and the generic --facet escape hatch append the same way; values + // are already split into lists, so each becomes one repeated query param. + const facets: Array<[string, string[]]> = [ + ["regions", opts.regions], + ["countries", opts.countries], + ["cities", opts.cities], + ["seniority", opts.seniority], + ["category", opts.category], + ["skills", opts.skills], + ...Object.entries(opts.facets), + ] + for (const [param, values] of facets) { + for (const value of values) p.append(param, value) + } + return p +} + +/** The date portion (YYYY-MM-DD) of an ISO timestamp, or "—" when absent. */ +function shortDate(date: string | null): string { + return date ? date.slice(0, 10) : "—" +} + +// Table columns: header, width, and the cell value. The SLUG column is sized to +// the longest slug so it is never truncated — a cut slug can't be looked up in +// `detail`; the fixed-width columns truncate for scanning. +interface Column { + header: string + width: number + cell: (r: JobResult) => string +} + +function renderTable(rows: JobResult[]): string { + if (rows.length === 0) return "No results." + const columns: Column[] = [ + { header: "SLUG", width: Math.max(4, ...rows.map((r) => r.id.length)), cell: (r) => r.id }, + { header: "TITLE", width: 38, cell: (r) => r.title }, + { header: "COMPANY", width: 22, cell: (r) => r.company ?? "—" }, + { header: "LOCATION", width: 20, cell: (r) => r.location ?? "—" }, + { header: "DATE", width: 10, cell: (r) => shortDate(r.date) }, + ] + const row = (cells: string[]) => cells.map((c, i) => c.slice(0, columns[i].width).padEnd(columns[i].width)).join(" ") + + const header = row(columns.map((c) => c.header)) + const body = rows.map((r) => row(columns.map((c) => c.cell(r)))) + return [header, "-".repeat(header.length), ...body].join("\n") +} + +function renderPlain(rows: JobResult[]): string { + if (rows.length === 0) return "No results." + const block = (r: JobResult) => + [ + r.title, + ` ${r.company ?? "—"} · ${r.location ?? "—"} · ${shortDate(r.date)}`, + ` slug: ${r.id}`, + ` ${r.url}`, + ].join("\n") + return rows.map(block).join("\n\n") +} + +export async function runSearch(opts: SearchOpts): Promise { + try { + const env = await apiGet(`/api/v1/jobs/search?${buildQuery(opts).toString()}`) + // The search endpoint returns an envelope; a null (404) is treated as empty. + const jobs = env?.data ?? [] + const rows = jobs.map(toResult) + const total = env?.meta?.total ?? rows.length + + if (opts.format === "table") { + process.stdout.write(renderTable(rows) + "\n") + } else if (opts.format === "plain") { + process.stdout.write(renderPlain(rows) + "\n") + } else { + process.stdout.write( + JSON.stringify( + { meta: { count: rows.length, page: opts.page, total }, results: rows }, + null, + 2, + ) + "\n", + ) + } + return 0 + } catch (e) { + writeError(e instanceof Error ? e.message : String(e), "SEARCH_FAILED") + return 1 + } +} diff --git a/.agents/skills/freehire-search/cli/src/helpers.ts b/.agents/skills/freehire-search/cli/src/helpers.ts new file mode 100644 index 0000000..694e5a1 --- /dev/null +++ b/.agents/skills/freehire-search/cli/src/helpers.ts @@ -0,0 +1,223 @@ +// Data source: the freehire.dev public REST API (JSON, `{data, meta}` envelope). +// Reads are unauthenticated — no API key, the same bar as linkedin-search — and +// unlike the HTML-scraping portals there is no markup to parse: we fetch JSON and +// reshape it into the portal-skill contract's result fields. The base URL is +// swappable via FREEHIRE_API_URL for self-hosting. + +export const DEFAULT_BASE_URL = "https://freehire.dev" + +/** API base URL: FREEHIRE_API_URL (for a self-hosted instance) or the default. */ +export function baseUrl(): string { + const raw = (process.env.FREEHIRE_API_URL ?? "").trim() + return (raw || DEFAULT_BASE_URL).replace(/\/+$/, "") +} + +export function writeError(error: string, code: string): void { + process.stderr.write(JSON.stringify({ error, code }) + "\n") +} + +const UA = "freehire-search-skill/1.0 (+https://freehire.dev)" + +/** The shared API response envelope: {data, meta, error}. */ +export interface Envelope { + data: T + meta?: { total?: number; limit?: number; offset?: number } + error?: string +} + +/** + * GET a JSON envelope from the freehire API. Retries 429/5xx (transient server + * states) with backoff; returns `null` on a 404. A connection failure fails fast + * with a clear message — no retry, so an outage degrades this source quickly + * rather than hanging the caller (the graceful-degradation contract). + */ +export async function apiGet(path: string): Promise | null> { + const url = `${baseUrl()}${path}` + const maxRetries = 6 + let delay = 500 + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + let response: Response + try { + response = await fetch(url, { + headers: { "User-Agent": UA, Accept: "application/json" }, + redirect: "follow", + }) + } catch (e) { + // Connection refused / DNS failure / timeout: the API is unreachable. + throw new Error( + `could not reach the freehire API at ${baseUrl()} (${e instanceof Error ? e.message : String(e)})`, + ) + } + + if (response.status === 429 || response.status >= 500) { + if (attempt === maxRetries) { + throw new Error(`freehire API request failed: ${response.status} ${response.statusText}`) + } + await sleep(delay + Math.floor(Math.random() * 500)) + delay = Math.min(delay * 2, 8000) + continue + } + if (response.status === 404) return null + + // Read the body once, tolerantly: an error response's JSON gives us its + // `error` message; a 2xx must parse (a malformed one is surfaced, not swallowed). + const body = (await response.json().catch(() => null)) as Envelope | null + if (!response.ok) { + throw new Error(body?.error || `freehire API request failed: ${response.status} ${response.statusText}`) + } + if (!body) throw new Error("freehire API returned an unparseable response body") + return body + } + // Unreachable in practice; the loop returns or throws on the last attempt. + throw new Error("freehire API request failed after retries") +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + +/** + * A freehire job — the fields this skill reads (the wire shape carries more). + */ +export interface FreehireJob { + public_slug: string + source: string + external_id: string + url: string + title: string + company: string + company_slug: string + location: string + description: string + skills: string[] + work_mode?: string + regions: string[] + countries: string[] + cities: string[] + posted_at: string | null + created_at: string | null + // Always present in the wire shape (an unenriched job serializes it as `{}`); + // the individual fields are what may be absent. + enrichment: { + seniority?: string + category?: string + employment_type?: string + salary_min?: number + salary_max?: number + salary_currency?: string + } +} + +/** + * A search result in the portal-skill contract shape. `id` is the public_slug + * (what `detail ` consumes) and `date` is the posting date; missing values + * are `null`, never omitted. The extra facet fields are a permitted superset. + */ +export interface JobResult { + id: string + title: string + company: string | null + company_slug: string | null + location: string | null + date: string | null + url: string + work_mode: string | null + regions: string[] + countries: string[] + skills: string[] +} + +/** A job detail: the search result plus the cleaned description and enrichment. */ +export interface JobDetailResult extends JobResult { + cities: string[] + seniority: string | null + category: string | null + employment_type: string | null + salary: string | null + description: string | null +} + +/** Reshape a freehire job into the contract search-result fields. */ +export function toResult(j: FreehireJob): JobResult { + return { + id: j.public_slug, + title: j.title || "(untitled)", + company: j.company || null, + company_slug: j.company_slug || null, + location: j.location || null, + date: j.posted_at, + url: j.url, + work_mode: j.work_mode || null, + regions: j.regions, + countries: j.countries, + skills: j.skills, + } +} + +/** Reshape a freehire job into the detail result (adds cleaned description + enrichment). */ +export function toDetail(j: FreehireJob): JobDetailResult { + const e = j.enrichment + return { + ...toResult(j), + cities: j.cities, + seniority: e.seniority || null, + category: e.category || null, + employment_type: e.employment_type || null, + salary: formatSalary(e), + description: cleanHtml(j.description), + } +} + +/** Human-readable salary line from the enrichment fields, or null when absent. */ +function formatSalary(e: FreehireJob["enrichment"]): string | null { + if (e.salary_min == null && e.salary_max == null) return null + const cur = e.salary_currency ? `${e.salary_currency} ` : "" + if (e.salary_min != null && e.salary_max != null) return `${cur}${e.salary_min}–${e.salary_max}` + return `${cur}${e.salary_min ?? e.salary_max}` +} + +function numericEntity(cp: number): string { + return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : "" +} + +function decodeHtmlEntities(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10))) + .replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16))) + .replace(/ /g, " ") +} + +/** + * Strip a freehire description's HTML into readable prose: block/line-break tags + * become newlines, entities are decoded, tags removed. Null for empty input. + */ +export function cleanHtml(html: string | null | undefined): string | null { + if (!html) return null + const withBreaks = html + .replace(/<\s*br\s*\/?>/gi, "\n") + .replace(/<\/(p|li|ul|ol|div|h\d)>/gi, "\n") + const text = decodeHtmlEntities(withBreaks.replace(/<[^>]+>/g, " ")) + .replace(/[ \t]+/g, " ") + .replace(/ *\n */g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim() + return text || null +} + +/** Extract a freehire public slug from a bare slug or a /jobs/ URL. */ +export function normalizeSlug(input: string): string | null { + const trimmed = input.trim() + if (!trimmed) return null + const m = trimmed.match(/\/jobs\/([^/?#]+)/) + if (m) return m[1] + // A bare slug: lowercase alphanumerics and hyphens (no path/scheme). + if (/^[a-z0-9][a-z0-9-]*$/i.test(trimmed)) return trimmed + return null +} diff --git a/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts new file mode 100644 index 0000000..7470fa3 --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/cli-flag-validation.test.ts @@ -0,0 +1,69 @@ +import { describe, test, expect } from "bun:test"; +import { runCLI } from "./helpers"; + +// These assert on validation error codes that are emitted BEFORE any network +// call (or independently of it), so the suite is network-free: a valid-flag case +// still runs offline because it only checks the ABSENCE of a validation error. + +function parsedStderr(stderr: string): { error?: string; code?: string } { + try { + return JSON.parse(stderr); + } catch { + return {}; + } +} + +describe("freehire CLI flag validation", () => { + describe("numeric flag validation", () => { + for (const name of ["jobage", "page", "limit"]) { + test(`--${name} non-numeric exits 1 with BAD_ARG`, async () => { + const result = await runCLI(["search", `--${name}`, "foo"]); + expect(result.exitCode).not.toBe(0); + const err = parsedStderr(result.stderr); + expect(err.code).toBe("BAD_ARG"); + expect(err.error).toMatch(new RegExp(name)); + }); + } + + test("valid integers produce no BAD_ARG", async () => { + const result = await runCLI(["search", "--jobage", "7", "--page", "1", "--limit", "1"]); + expect(parsedStderr(result.stderr).code).not.toBe("BAD_ARG"); + }); + }); + + describe("--facet validation", () => { + test("a facet without '=' exits 1 with BAD_ARG", async () => { + const result = await runCLI(["search", "--facet", "novalue"]); + expect(result.exitCode).not.toBe(0); + expect(parsedStderr(result.stderr).code).toBe("BAD_ARG"); + }); + }); + + describe("detail argument validation", () => { + test("missing slug exits 1 with NO_ID", async () => { + const result = await runCLI(["detail"]); + expect(result.exitCode).not.toBe(0); + expect(parsedStderr(result.stderr).code).toBe("NO_ID"); + }); + + test("an unparseable slug exits 1 with BAD_ID (no network)", async () => { + const result = await runCLI(["detail", "not a slug!"]); + expect(result.exitCode).not.toBe(0); + expect(parsedStderr(result.stderr).code).toBe("BAD_ID"); + }); + }); + + describe("command dispatch", () => { + test("unknown command exits 1 with BAD_CMD", async () => { + const result = await runCLI(["frobnicate"]); + expect(result.exitCode).not.toBe(0); + expect(parsedStderr(result.stderr).code).toBe("BAD_CMD"); + }); + + test("no command prints help and exits 1", async () => { + const result = await runCLI([]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toMatch(/USAGE/); + }); + }); +}); diff --git a/.agents/skills/freehire-search/cli/tests/commands.test.ts b/.agents/skills/freehire-search/cli/tests/commands.test.ts new file mode 100644 index 0000000..2032152 --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/commands.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { runSearch } from "../src/commands/search"; +import { runDetail } from "../src/commands/detail"; +import type { FreehireJob } from "../src/helpers"; + +const originalFetch = globalThis.fetch; +const originalStdoutWrite = process.stdout.write; + +function captureStdout(): { get: () => string } { + let buf = ""; + process.stdout.write = ((chunk: string | Uint8Array) => { + buf += chunk.toString(); + return true; + }) as typeof process.stdout.write; + return { get: () => buf }; +} + +function mockFetch(status: number, body: unknown): void { + globalThis.fetch = (async () => + new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + })) as typeof fetch; +} + +function job(overrides: Partial = {}): FreehireJob { + return { + public_slug: "backend-engineer-acme-ab12cd34", + source: "greenhouse", + external_id: "acme:1", + url: "https://boards.greenhouse.io/acme/jobs/1", + title: "Backend Engineer", + company: "Acme", + company_slug: "acme", + location: "Berlin, Germany", + description: "

Build things

", + skills: ["go"], + work_mode: "remote", + regions: ["eu"], + countries: ["de"], + cities: ["Berlin"], + posted_at: "2026-07-06T00:00:00Z", + created_at: "2026-07-06T15:00:00Z", + enrichment: {}, + ...overrides, + }; +} + +afterEach(() => { + globalThis.fetch = originalFetch; + process.stdout.write = originalStdoutWrite; +}); + +const searchOpts = { + jobage: 9999, + page: 1, + limit: 25, + format: "json" as const, + regions: [] as string[], + countries: [] as string[], + cities: [] as string[], + seniority: [] as string[], + category: [] as string[], + skills: [] as string[], + facets: {} as Record, +}; + +describe("runSearch (mocked fetch)", () => { + test("emits the contract envelope with meta.count/page/total", async () => { + mockFetch(200, { data: [job()], meta: { total: 42, limit: 25, offset: 0 } }); + const out = captureStdout(); + + const code = await runSearch({ ...searchOpts, query: "backend" }); + expect(code).toBe(0); + + const parsed = JSON.parse(out.get()); + expect(parsed.meta).toEqual({ count: 1, page: 1, total: 42 }); + expect(parsed.results).toHaveLength(1); + expect(parsed.results[0].id).toBe("backend-engineer-acme-ab12cd34"); + expect(parsed.results[0].date).toBe("2026-07-06T00:00:00Z"); + }); + + test("empty result set yields an empty results array", async () => { + mockFetch(200, { data: [], meta: { total: 0 } }); + const out = captureStdout(); + + const code = await runSearch({ ...searchOpts, query: "nothing-matches-xyz" }); + expect(code).toBe(0); + expect(JSON.parse(out.get()).results).toHaveLength(0); + }); + + test("network failure exits 1 with SEARCH_FAILED", async () => { + globalThis.fetch = (async () => { + throw new Error("ECONNREFUSED"); + }) as typeof fetch; + let err = ""; + const origErr = process.stderr.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + err += chunk.toString(); + return true; + }) as typeof process.stderr.write; + + const code = await runSearch({ ...searchOpts, query: "backend" }); + process.stderr.write = origErr; + + expect(code).toBe(1); + expect(JSON.parse(err).code).toBe("SEARCH_FAILED"); + }); +}); + +describe("runDetail (mocked fetch)", () => { + test("prints the reshaped detail with a cleaned description", async () => { + mockFetch(200, { data: job() }); + const out = captureStdout(); + + const code = await runDetail({ id: "backend-engineer-acme-ab12cd34", format: "json" }); + expect(code).toBe(0); + + const parsed = JSON.parse(out.get()); + expect(parsed.id).toBe("backend-engineer-acme-ab12cd34"); + expect(parsed.description).toBe("Build things"); + expect(parsed.cities).toEqual(["Berlin"]); + }); + + test("404 exits 1 with NOT_FOUND", async () => { + mockFetch(404, { error: "not found" }); + let err = ""; + const origErr = process.stderr.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + err += chunk.toString(); + return true; + }) as typeof process.stderr.write; + + const code = await runDetail({ id: "does-not-exist", format: "json" }); + process.stderr.write = origErr; + + expect(code).toBe(1); + expect(JSON.parse(err).code).toBe("NOT_FOUND"); + }); +}); diff --git a/.agents/skills/freehire-search/cli/tests/helpers.ts b/.agents/skills/freehire-search/cli/tests/helpers.ts new file mode 100644 index 0000000..ff2b9a0 --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/helpers.ts @@ -0,0 +1,40 @@ +import { join } from "path"; + +const CLI_PATH = join(import.meta.dir, "../src/cli.ts"); + +export interface CLIResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export async function runCLI(args: string[], env: Record = {}): Promise { + const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, ...env }, + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; +} + +export function parseJSON(result: CLIResult): T { + if (result.exitCode !== 0) { + throw new Error( + `CLI exited with code ${result.exitCode}. stderr: ${result.stderr}` + ); + } + try { + return JSON.parse(result.stdout) as T; + } catch { + throw new Error( + `Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + } +} diff --git a/.agents/skills/freehire-search/cli/tests/parsing.test.ts b/.agents/skills/freehire-search/cli/tests/parsing.test.ts new file mode 100644 index 0000000..fe21aa6 --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/parsing.test.ts @@ -0,0 +1,102 @@ +import { describe, test, expect } from "bun:test"; +import { cleanHtml, normalizeSlug, toResult, toDetail, type FreehireJob } from "../src/helpers"; + +function job(overrides: Partial = {}): FreehireJob { + return { + public_slug: "backend-engineer-acme-ab12cd34", + source: "greenhouse", + external_id: "acme:1", + url: "https://boards.greenhouse.io/acme/jobs/1", + title: "Backend Engineer", + company: "Acme", + company_slug: "acme", + location: "Berlin, Germany", + description: "
  • Build APIs
  • Ship & iterate
", + skills: ["go", "kubernetes"], + work_mode: "remote", + regions: ["eu"], + countries: ["de"], + cities: ["Berlin"], + posted_at: "2026-07-06T00:00:00Z", + created_at: "2026-07-06T15:00:00Z", + enrichment: {}, + ...overrides, + }; +} + +describe("toResult — reshape into the portal-skill contract", () => { + test("maps public_slug -> id and posted_at -> date", () => { + const r = toResult(job()); + expect(r.id).toBe("backend-engineer-acme-ab12cd34"); + expect(r.date).toBe("2026-07-06T00:00:00Z"); + }); + + test("carries the required contract fields", () => { + const r = toResult(job()); + expect(r).toMatchObject({ + title: "Backend Engineer", + company: "Acme", + company_slug: "acme", + location: "Berlin, Germany", + url: "https://boards.greenhouse.io/acme/jobs/1", + }); + }); + + test("missing values are null, not omitted", () => { + const r = toResult(job({ company: "", location: "", posted_at: null, work_mode: undefined })); + expect(r.company).toBeNull(); + expect(r.location).toBeNull(); + expect(r.date).toBeNull(); + expect(r.work_mode).toBeNull(); + }); +}); + +describe("toDetail — adds cleaned description + enrichment", () => { + test("strips HTML and decodes entities in the description", () => { + const d = toDetail(job()); + expect(d.description).toBe("Build APIs\nShip & iterate"); + }); + + test("surfaces enrichment fields and a formatted salary", () => { + const d = toDetail( + job({ + enrichment: { seniority: "senior", category: "backend", employment_type: "full_time", salary_min: 90000, salary_max: 120000, salary_currency: "EUR" }, + }), + ); + expect(d.seniority).toBe("senior"); + expect(d.category).toBe("backend"); + expect(d.salary).toBe("EUR 90000–120000"); + }); + + test("null enrichment fields when the enrichment object is empty", () => { + const d = toDetail(job({ enrichment: {} })); + expect(d.seniority).toBeNull(); + expect(d.salary).toBeNull(); + }); +}); + +describe("cleanHtml", () => { + test("preserves paragraph breaks between blocks", () => { + expect(cleanHtml("

One

Two

")).toBe("One\nTwo"); + }); + test("decodes hex numeric entities", () => { + expect(cleanHtml("Café")).toBe("Café"); + }); + test("returns null for empty input", () => { + expect(cleanHtml("")).toBeNull(); + expect(cleanHtml(null)).toBeNull(); + }); +}); + +describe("normalizeSlug", () => { + test("accepts a bare slug", () => { + expect(normalizeSlug("golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm"); + }); + test("extracts the slug from a /jobs/ URL", () => { + expect(normalizeSlug("https://freehire.dev/jobs/golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm"); + }); + test("rejects a non-slug string", () => { + expect(normalizeSlug("not a slug!")).toBeNull(); + expect(normalizeSlug("")).toBeNull(); + }); +}); diff --git a/.agents/skills/freehire-search/cli/tsconfig.json b/.agents/skills/freehire-search/cli/tsconfig.json new file mode 100644 index 0000000..9e46917 --- /dev/null +++ b/.agents/skills/freehire-search/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} diff --git a/.agents/skills/freehire-search/url-reference.md b/.agents/skills/freehire-search/url-reference.md new file mode 100644 index 0000000..f293d69 --- /dev/null +++ b/.agents/skills/freehire-search/url-reference.md @@ -0,0 +1,114 @@ +# 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": "" }` 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) + +```jsonc +{ + "public_slug": "golang-zensar-2bxu6dxm", // -> result.id, and detail's + "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": "
", // 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 `{ : { : } }`. This skill +does not call it programmatically, but it is the vocabulary source the SKILL.md +points users to (`?q=` 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 (`cleanHtml` in `cli/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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90d50b1..0279bed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,7 @@ jobs: fail-fast: false matrix: tool: + - freehire-search - jobbank-search - jobdanmark-search - jobindex-search