import { apiGet, toResult, writeError, type FreehireJob, type JobResult } from "../helpers.js" // The agent variant of the job search: the same query, ranking, and facets as the // web's /jobs/search, but each hit carries the posting's full description instead // of the search index's truncated preview — so a run reads every result without a // follow-up `detail` per hit. const SEARCH_PATH = "/api/v1/agent/jobs/search" /** How the API renders each result's full description. */ export type DescriptionFormat = "markdown" | "text" | "html" export const DESCRIPTION_FORMATS: DescriptionFormat[] = ["markdown", "text", "html"] export interface SearchOpts { query?: string jobage: number page: number limit: number format: "json" | "table" | "plain" descriptionFormat: DescriptionFormat // 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 // The agent endpoint serves the index's truncated preview unless asked to // rehydrate each hit from the database, so both params travel together. p.set("include_description", "true") p.set("description_format", opts.descriptionFormat) 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(`${SEARCH_PATH}?${buildQuery(opts).toString()}`) // A 404 here is a missing endpoint, not a missing job: a freehire instance // older than the agent search surface answers that way, and reporting it as // an empty result set would hide the misconfiguration behind plausible output. if (!env) { writeError( `${SEARCH_PATH} not found — this freehire instance predates the agent search endpoint; upgrade it or unset FREEHIRE_API_URL to use the hosted API`, "SEARCH_FAILED", ) return 1 } const rows = (env.data ?? []).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 } }