Files
ai-job-search/.agents/skills/freehire-search/cli/src/commands/detail.ts
T
Ilya Strelov b8d35a4b69 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.
2026-07-09 06:04:35 +02:00

66 lines
2.3 KiB
TypeScript

import { apiGet, normalizeSlug, toDetail, writeError, type FreehireJob, type JobDetailResult } from "../helpers.js"
export interface DetailOpts {
id: string // a freehire public slug or a /jobs/<slug> 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<number> {
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<FreehireJob>(`/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
}
}