* fix(freehire-search): reject an unknown --remote mode
--remote foo was forwarded as work_mode=foo and the search exited 0 with
an empty result set, so a typo looked like no matching jobs. Validate
against the documented remote|hybrid|onsite values (plus on-site) before
dispatch; a bare --remote still means remote.
* fix(freehire-search): accept comma-separated --remote modes
freehire ORs a comma-separated work_mode, so --remote remote,hybrid is
a valid query. Split --remote on commas, normalise each item (on-site
becomes onsite) and re-join them. An unknown item such as foo in
remote,foo still exits 1 with BAD_ARG naming that item.
* changelog: place the #661 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* freehire-search: document the OR and title: operators in -q
freehire's search now parses two operators in q: uppercase OR between
up to five alternatives (a union, filtered and paged as one result),
and a leading title: that restricts an alternative to the job title.
The CLI already passes -q through unchanged, so both work today; this
tells the agent they exist, in SKILL.md, --help, the CLI README and
url-reference.md.
* changelog: place the #674 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(freehire-search): warn when --region gets a value outside the vocabulary
freehire's contract - the openapi Regions enum and RegionValues in vocab.go -
defines a closed set: nine buckets plus none. Anything else is a stray tag, so a
region filter on us or any other country code matches a handful of postings and
reads as an empty or thin market, which is how #616 started.
search now checks the requested values (--region and the --facet regions= escape
hatch, case-insensitively) against that set and, when one falls outside it, writes
one JSON line on stderr after the results, with code REGION_VALUE_UNKNOWN. The
result count is deliberately not consulted: a broad query returns real hits under a
stray tag and the parameter is still wrong. stdout stays clean JSON, exit code 0.
Tests are unit-level with a stubbed fetch: no extra request to the free API, and no
change to the existing search tests.
* changelog: place the #651 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* freehire-search: us is a country, not a region
The docs and --help listed us as a --region value. In freehire's location
dictionary us is a country code and the region is north_america, so
--region us matched 168 'software engineer' postings against 100008 for
--country us. List the real region vocabulary and send single-country
searches to --country.
* freehire-search: add uk to the region list; fix the README row
* changelog: place the #630 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(freehire-search): reject detail URLs on other hosts instead of asking the API for their last segment
normalizeSlug took the segment after "/jobs/" from ANY string, so a
Greenhouse apply link - which is what a freehire search result's own url
field carries - was reduced to its last path segment and detail
requested /api/v1/jobs/1, then reported NOT_FOUND for a URL that was
never a freehire posting. Every other portal CLI rejects an off-host
detail URL with BAD_ID (#447, #430, #499); freehire was the last one
trusting the path.
Parse URLs for real: freehire.me (apex or any subdomain, scheme optional)
or the host of FREEHIRE_API_URL for a self-hosted instance, plus a
/jobs/<slug> path, yields the slug; other hosts, look-alike and userinfo
hosts, and non-job freehire pages exit 1 with BAD_ID before any request.
Bare slugs are unchanged.
Five new normalizeSlug cases and a handler-level test that pastes the
search fixture's own apply URL into detail with fetch stubbed; the
off-host cases fail on master.
* changelog: place the #623 entry under [Unreleased]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
semantic_ratio and include_description are both reported in
meta.ignored_params. With include_description ignored, the agent endpoint
hydrates every full body from the database, and --no-description threw
that payload away client-side. Send --no-description to /api/v1/jobs/search,
which serves only the preview, and stop sending the two unread params.
toResult passed posted_at through verbatim, so search and detail JSON
carried "date": "2026-07-06T00:00:00Z" while every other portal CLI
emits the date part only and /scrape stores it as
posted_date: "YYYY-MM-DD". The table and plain renderers already cut the
value to ten characters, so only JSON consumers - the /scrape path - saw
the raw timestamp.
Add contractDate(): the timestamp's date part, null for an absent or
non-date value. The two tests that pinned the raw timestamp now expect
the date and fail on master, plus three cases on the helper. SKILL.md
documents the shape.
formatSalary printed only the currency and amounts, although freehire's
enrichment records the pay period. `detail software-developer-trainee-codifi-fra635ux`
printed `INR 300000–300000` while the API returns `salary_period: "year"`
for that posting, so a yearly salary screened against a monthly figure
read as twelve times its real monthly value.
Append the period when freehire records one (`INR 300000–300000/year`,
`INR 12000/month`); a record without a period prints exactly as before.
Two new parsing.test.ts cases pin it; both fail on the old formatter.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A default search hydrates full bodies - ~73% of the payload, ~20k tokens
per query fed into agent context - while /scrape's own Step 2 says to
pre-filter by title before reading bodies. The flag keeps every other
field and drops the bodies (live 10-result search: ~58k -> ~10k chars);
hydration stays the default per the documented trade-off. The API
returns bodies regardless of include_description=false (verified live),
so the lean guarantee is enforced client-side. Review opportunity O1
(2026-08-19), approved as an enhancement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skill queried /api/v1/jobs/search, whose `description` is the search
index's truncated preview — and the CLI dropped it entirely, so a result
carried only title/company/location/date/url. Reading a posting therefore
meant a `detail` call per hit, which is exactly what job-scraper's Step 2
prescribes: "fetch full detail with that portal's `detail` command".
freehire exposes a search endpoint for programmatic consumers,
/api/v1/agent/jobs/search: same query, ranking, facets and pagination, but
asked to (`include_description=true`) it replaces the preview with the
posting's full description read from the database, rendered as
`description_format=markdown|text|html`. Reproduce the difference:
curl -s "https://freehire.me/api/v1/jobs/search?q=golang&limit=1" \
| jq -r '.data[0].description | length' # preview, capped
curl -s "https://freehire.me/api/v1/agent/jobs/search?q=golang&limit=1\
&include_description=true&description_format=markdown" \
| jq -r '.data[0].description | length' # full text
So `search` now calls that endpoint, always asking for full descriptions,
and each JSON result carries `description` verbatim — no client-side HTML
stripping, since the API already rendered it. Markdown is the default
because it preserves the headings and requirement lists /rank reasons over;
`--description-format text|html` selects the others. The flag is validated
client-side: the API answers an unrecognized format with raw HTML rather
than an error, so a typo would silently change the output instead of
failing.
`table` and `plain` stay description-free — a full posting body would swamp
a scannable list — and `detail` is untouched, for looking one posting up by
slug (including a closed one, absent from search).
One behaviour change beyond the endpoint: a 404 from the search path used
to be folded into an empty result set. On the agent endpoint a 404 means
the instance predates it — a self-hosted freehire behind FREEHIRE_API_URL —
so it is now reported as an error naming the path, instead of a plausible
"no results" that hides the misconfiguration.
Tests cover the requested URL and params, verbatim (unstripped) markdown,
the null-when-absent case, the 404-is-an-error contract, and the flag
validation. All network-free.
freehire moved its primary domain from freehire.dev to freehire.me. Update the
freehire-search skill's default API base URL, help text, docs, and examples.
Backward-compatible: FREEHIRE_API_URL still overrides the base (self-hosting),
and normalizeSlug is host-agnostic so pasted freehire.dev/jobs/<slug> URLs still
resolve. The GitHub repo link (github.com/strelov1/freehire) is unchanged. All
27 CLI tests pass; the freehire.me API answers 200 for /jobs/search + /jobs/facets.
Implements the mechanism approved in discussion #93: enabled: true|false frontmatter on portal skills (missing key = enabled), honored during /scrape portal discovery, with skipped portals reported visibly in the run summary.
* 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.