* test(cli): pin the 429/5xx retry contract in all six portal CLIs The portal-skill contract requires backoff on 429/5xx, and every CLI implements it - a retry loop with exponential delay and jitter - but nothing verified the loops actually retry, stop retrying on plain 4xx, or give up after the documented attempt budget. A regression here is invisible: a CLI that stops retrying still works on every healthy request. Each CLI gains tests/retry-backoff.test.ts, network-free, using the request-timeout.test.ts pattern from #197 (import the fetch wrapper, stub globalThis.fetch): a stubbed fetch counts attempts, and a stubbed setTimeout fires immediately so the exhaustion case does not sleep through the real 500ms -> 5s/8s backoff schedule (tests run in milliseconds, not ~17s). Three assertions per fetch wrapper, adapted to each CLI's documented semantics: - a 429 is retried and the next attempt's result is returned - a plain 4xx is not retried (jobbank's fetchWithUA RETURNS the response for callers to handle - pinned as such; linkedin's htmlFetch returns "" on 404; freehire's apiGet returns null) - persistent 5xx gives up after the initial attempt plus six retries (7 fetch calls) with the status in the error freehire additionally pins its documented graceful-degradation contract: a connection failure fails fast with no retry. jobdanmark exercises both apiFetch and apiPost, which carry separate copies of the loop that could drift apart. Mutation-checked: changing maxRetries in jobindex makes the exhaustion test fail, so the tests distinguish the current behavior from a silently altered one. Verified: bun test green in all six CLIs (jobindex 19, jobnet 20, jobbank 20, jobdanmark 21, linkedin 21, freehire 31 - 0 fail); tsc --noEmit clean in all six; python3 tools/lint_skills.py OK. * test(jobindex): pin apiFetch's retry loop alongside htmlFetch's Review parity gap: jobindex carries two separate copies of the retry loop and only htmlFetch was exercised, so apiFetch's retry budget could drift silently - the same situation jobdanmark's test already handles for its apiFetch/apiPost pair. apiFetch gets the same three assertions, adapted to its documented semantics (JSON return on success, throw on plain 4xx): a 429 is retried and the next attempt's parsed body returned, a 400 is not retried, persistent 5xx gives up after the initial attempt plus six retries (7 calls). Mutation-checked on the new axis: changing apiFetch's maxRetries (the file's first copy of the loop) fails its exhaustion test while htmlFetch's tests stay green, so each wrapper is now pinned independently. Verified: bun test 30 pass / 0 fail (full jobindex suite); tsc --noEmit clean.
freehire-cli
CLI for searching the freehire.me job aggregator across many markets (tech-focused), via its public JSON API.
Data source: freehire.me 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.me, 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_URLat a self-hosted freehire backend to swap the source.
Installation
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.me and is overridable with an env var:
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
# 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, or narrow it,
e.g. https://freehire.me/api/v1/jobs/facets?q=react.