From 1c95c471ec248ad69ddf5db77ce8a807cbcf8e3f Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 19 Oct 2026 12:43:37 -0700 Subject: [PATCH] fix(freehire-search): reject an unknown --remote mode (#661) * 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 --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Mads Lorentzen Co-authored-by: Claude Opus 5.5 --- .agents/skills/freehire-search/SKILL.md | 2 +- .agents/skills/freehire-search/cli/src/cli.ts | 51 +++++++++- .../cli/tests/remote-mode.test.ts | 94 +++++++++++++++++++ CHANGELOG.md | 2 + 4 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 .agents/skills/freehire-search/cli/tests/remote-mode.test.ts diff --git a/.agents/skills/freehire-search/SKILL.md b/.agents/skills/freehire-search/SKILL.md index e6500867..76bf4990 100644 --- a/.agents/skills/freehire-search/SKILL.md +++ b/.agents/skills/freehire-search/SKILL.md @@ -129,7 +129,7 @@ Facet filters (values come from freehire's controlled vocabularies; comma-separa - `--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) +- `--remote ` — `remote` | `hybrid` | `onsite` (`work_mode` facet). A bare `--remote` means `remote`. Comma-separate for OR (`remote,hybrid`; `on-site` normalises to `onsite`). An unknown item exits 1 with `BAD_ARG`. - `--facet ` — any other facet param (repeatable), e.g. `--facet salary_min=100000` > **Location is a facet, not free text.** Unlike `linkedin-search`'s `--location`, diff --git a/.agents/skills/freehire-search/cli/src/cli.ts b/.agents/skills/freehire-search/cli/src/cli.ts index bd9773b8..54c45790 100644 --- a/.agents/skills/freehire-search/cli/src/cli.ts +++ b/.agents/skills/freehire-search/cli/src/cli.ts @@ -101,6 +101,7 @@ FACET FILTERS (values from freehire.me's controlled vocabularies; comma = OR) --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). + A bare --remote means remote. Comma = OR. Unknown values exit 1. --facet Any other facet param (repeatable), e.g. --facet salary_min=100000 DETAIL @@ -117,6 +118,42 @@ 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. ` +const WORK_MODES = ["remote", "hybrid", "onsite"] as const +type WorkMode = (typeof WORK_MODES)[number] + +/** + * Map --remote to the work_mode facet. + * A bare --remote (boolean true) is the documented "remote" shortcut. + * Comma-separated values are OR: each item is normalised (`on-site` -> + * `onsite`) and re-joined. An unknown item is rejected (the API treats it + * as a filter that matches nothing) and `bad` names that item. + */ +function parseWorkMode(raw: FlagValue): { value?: string; bad?: string } { + if (raw === undefined) return {} + if (raw === true) return { value: "remote" } + if (typeof raw !== "string" || !raw.trim()) return { bad: String(raw) } + const modes: WorkMode[] = [] + for (const item of raw.split(",")) { + const token = item.trim() + if (!token) continue + switch (token.toLowerCase()) { + case "remote": + modes.push("remote") + break + case "hybrid": + modes.push("hybrid") + break + case "onsite": + case "on-site": + modes.push("onsite") + break + default: + return { bad: token } + } + } + return modes.length > 0 ? { value: modes.join(",") } : { bad: raw.trim() } +} + function parseIntFlag(name: string, raw: string | boolean | string[]): number | null { // Number(), not parseInt(): parseInt truncates, so "--jobage 0.5" became 0, // which fails search.ts's `jobage > 0` guard and silently drops @@ -234,6 +271,17 @@ async function main(): Promise { facets[key] = (facets[key] ?? []).concat(vals) } + const parsedRemote = parseWorkMode(flags.remote) + if (parsedRemote.bad !== undefined) { + process.stderr.write( + JSON.stringify({ + error: `--remote must be one of remote|hybrid|onsite, got "${parsedRemote.bad}"`, + code: "BAD_ARG", + }) + "\n", + ) + return 1 + } + const opts: SearchOpts = { query: stringFlag(flags.query), jobage: flags.jobage ? parseInt(flags.jobage as string, 10) : 9999, @@ -249,8 +297,7 @@ async function main(): Promise { 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"), + workMode: parsedRemote.value, facets, } return runSearch(opts) diff --git a/.agents/skills/freehire-search/cli/tests/remote-mode.test.ts b/.agents/skills/freehire-search/cli/tests/remote-mode.test.ts new file mode 100644 index 00000000..c584c40f --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/remote-mode.test.ts @@ -0,0 +1,94 @@ +import { describe, test, expect } from "bun:test"; +import { runCLI } from "./helpers"; + +function parsedStderr(stderr: string): { error?: string; code?: string } { + try { + return JSON.parse(stderr); + } catch { + return {}; + } +} + +async function searchRemote(args: string[]): Promise<{ result: Awaited>; requests: URL[] }> { + const requests: URL[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + requests.push(new URL(request.url)); + return Response.json({ data: [], meta: { total: 0 } }); + }, + }); + try { + const result = await runCLI(["search", "-q", "go", "--limit", "1", ...args], { + FREEHIRE_API_URL: server.url.toString(), + }); + return { result, requests }; + } finally { + server.stop(true); + } +} + +describe("--remote work_mode validation", () => { + test("--remote foo exits 1 with BAD_ARG and does not call the API", async () => { + const { result, requests } = await searchRemote(["--remote", "foo"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(parsedStderr(result.stderr).code).toBe("BAD_ARG"); + expect(parsedStderr(result.stderr).error).toContain("--remote"); + expect(parsedStderr(result.stderr).error).toContain("foo"); + expect(requests).toEqual([]); + }); + + test("comma-separated modes are normalised and re-joined", async () => { + for (const [flag, expected] of [ + ["remote,hybrid", "remote,hybrid"], + ["remote,on-site", "remote,onsite"], + ] as const) { + const { result, requests } = await searchRemote(["--remote", flag]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(requests).toHaveLength(1); + expect(requests[0].searchParams.get("work_mode")).toBe(expected); + } + }); + + test("--remote remote,foo exits 1 with BAD_ARG naming foo and does not call the API", async () => { + const { result, requests } = await searchRemote(["--remote", "remote,foo"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(parsedStderr(result.stderr).code).toBe("BAD_ARG"); + expect(parsedStderr(result.stderr).error).toContain('"foo"'); + expect(requests).toEqual([]); + }); + + test("documented modes and on-site reach work_mode", async () => { + for (const [flag, expected] of [ + ["remote", "remote"], + ["hybrid", "hybrid"], + ["onsite", "onsite"], + ["on-site", "onsite"], + ["Remote", "remote"], + ] as const) { + const { result, requests } = await searchRemote(["--remote", flag]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(requests).toHaveLength(1); + expect(requests[0].searchParams.get("work_mode")).toBe(expected); + } + }); + + test("bare --remote still means work_mode=remote", async () => { + const { result, requests } = await searchRemote(["--remote"]); + expect(result.exitCode).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0].searchParams.get("work_mode")).toBe("remote"); + }); + + test("omitting --remote does not send work_mode", async () => { + const { result, requests } = await searchRemote([]); + expect(result.exitCode).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0].searchParams.has("work_mode")).toBe(false); + }); +}); diff --git a/CHANGELOG.md b/CHANGELOG.md index 3324f2c0..1da7a46d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -203,6 +203,8 @@ per-file diff commands. web search only for that last escalation step, finding the employer's own copy of the posting; company research and salary lookups stay out of `/rank`. +- **`freehire-search` rejects an unknown `--remote` mode instead of searching a facet that matches nothing** (#661, `.agents/skills/freehire-search/cli/src/cli.ts`, `cli/tests/remote-mode.test.ts`) - `--remote foo` was forwarded as `work_mode=foo` and the CLI exited 0 with an empty result set, so a typo looked like "no jobs". The documented `remote` / `hybrid` / `onsite` values, mixed case, and `on-site` now map to the facet; a bare `--remote` still means `remote`. Comma-separated modes are validated per item and re-joined (`remote,hybrid`, `remote,on-site` → `remote,onsite`); an unknown item such as `foo` in `remote,foo` exits 1 with `BAD_ARG` and does not call the API. + ### Security - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a