mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 16:46:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bf9a65212 | ||
|
|
73d2ebee52 | ||
|
|
7a753f3cd4 | ||
|
|
5a9f6c42a4 | ||
|
|
e3af401087 | ||
|
|
1c74a57c5e | ||
|
|
82a60300b6 | ||
|
|
41ddc0c73c | ||
|
|
1969d0ea70 | ||
|
|
2e654d68d2 | ||
|
|
b204c44fdb | ||
|
|
c7a1e0cf89 | ||
|
|
aa7c707399 | ||
|
|
1ae66ad094 | ||
|
|
7db231c680 | ||
|
|
3609f584b5 | ||
|
|
a68028bc54 |
@@ -3,7 +3,7 @@ name: freehire-search
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Use this skill to search live software / tech / data / engineering job listings
|
||||
across many countries and markets (and remote) via the freehire.dev aggregator's
|
||||
across many countries and markets (and remote) via the freehire.me aggregator's
|
||||
public API, or to look up a specific posting. It aggregates roles from ~50 ATS
|
||||
platforms into one schema, so a single skill covers many markets — but its faceted
|
||||
filtering (skills, category, seniority) is tuned tech-first, so scope triggers to
|
||||
@@ -17,7 +17,7 @@ allowed-tools: Bash(bun run .agents/skills/freehire-search/cli/src/cli.ts *)
|
||||
|
||||
# freehire Search Skill
|
||||
|
||||
Search live job listings from the **[freehire.dev](https://freehire.dev)** job
|
||||
Search live job listings from the **[freehire.me](https://freehire.me)** job
|
||||
aggregator — an open-source IT job board that normalizes postings from ~50 ATS
|
||||
platforms across many countries into one schema. No authentication, no API key,
|
||||
and **zero runtime dependencies** — it runs with just `bun`. The market is chosen
|
||||
@@ -40,10 +40,10 @@ coverage exists but is still maturing; don't rely on this skill for general
|
||||
|
||||
## ℹ️ Hosted-service dependency (best-effort, no SLA)
|
||||
|
||||
This skill depends on a third-party hosted service, freehire.dev. Reads are
|
||||
This skill depends on a third-party hosted service, freehire.me. Reads are
|
||||
**public and unauthenticated** — the same zero-signup bar as `linkedin-search`.
|
||||
|
||||
**freehire.dev is a personal project but actively maintained; it runs on a
|
||||
**freehire.me is a personal project but actively maintained; it runs on a
|
||||
best-effort basis (no formal SLA).** If the API is unreachable, the CLI fails
|
||||
gracefully — a non-zero exit with a clear error message — so an outage degrades
|
||||
this source rather than breaking the surrounding workflow.
|
||||
@@ -52,7 +52,7 @@ this source rather than breaking the surrounding workflow.
|
||||
MIT-licensed repo — [`strelov1/freehire`](https://github.com/strelov1/freehire)
|
||||
(Go + PostgreSQL + Meilisearch) — that stands up with one command via Docker
|
||||
Compose (`make up` → API on `:8080`, same `/api/v1/...` paths). The skill honors a
|
||||
base-URL env var, `FREEHIRE_API_URL` (default `https://freehire.dev`), so pointing
|
||||
base-URL env var, `FREEHIRE_API_URL` (default `https://freehire.me`), so pointing
|
||||
it at a local instance is a one-line change:
|
||||
|
||||
```bash
|
||||
@@ -66,9 +66,10 @@ at the hosted API.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- Search for tech job openings by keyword, in a given region/country or remotely
|
||||
- Search for tech job openings by keyword, in a given region/country or remotely —
|
||||
each result comes back with its **full description**, no per-hit follow-up needed
|
||||
- Filter by seniority, category, skills, or recency (posted within N days)
|
||||
- Get the full description of a specific freehire posting by its slug
|
||||
- Look one freehire posting up by its slug (including a closed one)
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -84,6 +85,17 @@ Key flags:
|
||||
- `--page <n>` — 1-indexed page. Default 1.
|
||||
- `--limit <n>` / `-n <n>` — results per page (API limit). Default 25.
|
||||
- `--format json|table|plain` — default `json`.
|
||||
- `--description-format markdown|text|html` — how each result's full description is
|
||||
rendered. Default `markdown`, which keeps the posting's headings and requirement
|
||||
lists intact. `json` output only.
|
||||
|
||||
**Search results already carry the full description.** This skill queries freehire's
|
||||
agent search endpoint, which replaces the index's truncated preview with each
|
||||
posting's complete text, so a search of 20 roles is 1 request rather than 1 + 20.
|
||||
Do **not** loop `detail` over search hits to read their descriptions — reach for
|
||||
`detail` only to look one posting up by slug (e.g. from the tracker, or a posting
|
||||
already closed and therefore absent from search). Full descriptions are verbose:
|
||||
keep `--limit` modest, and pre-filter on title/company before reading bodies.
|
||||
|
||||
Facet filters (values come from freehire's controlled vocabularies; comma-separate for OR within a facet):
|
||||
- `--region <codes>` — macro-region, e.g. `global`, `eu`, `us`, `apac`, `latam`, `cis`. `--region eu,us`. Use `none` to match jobs whose region could **not** be resolved (see "Partial data" below).
|
||||
@@ -99,7 +111,7 @@ Facet filters (values come from freehire's controlled vocabularies; comma-separa
|
||||
> **Location is a facet, not free text.** Unlike `linkedin-search`'s `--location`,
|
||||
> freehire filters geography through the structured `--region`/`--country`/`--city`
|
||||
> facets. Discover the live values for a market at
|
||||
> [`/api/v1/jobs/facets`](https://freehire.dev/api/v1/jobs/facets) (append `?q=<role>`
|
||||
> [`/api/v1/jobs/facets`](https://freehire.me/api/v1/jobs/facets) (append `?q=<role>`
|
||||
> to scope it) — never invent facet values.
|
||||
|
||||
### Fetch full job detail
|
||||
@@ -109,10 +121,14 @@ bun run .agents/skills/freehire-search/cli/src/cli.ts detail <slug|url> [--forma
|
||||
```
|
||||
|
||||
`slug` is the `id` from a `search` result (e.g. `golang-zensar-2bxu6dxm`). You may
|
||||
also pass a full `https://freehire.dev/jobs/<slug>` URL. Returns the full (HTML-stripped)
|
||||
also pass a full `https://freehire.me/jobs/<slug>` URL. Returns the full (HTML-stripped)
|
||||
description, skills, region/country, and — when the posting is enriched — seniority,
|
||||
category, employment type, and salary.
|
||||
|
||||
Use it for a posting you already have a slug for — a tracked application, a shared
|
||||
link, or a closed posting search no longer lists. Re-fetching a hit that `search`
|
||||
just returned only re-reads a description you already have.
|
||||
|
||||
## Usage examples
|
||||
|
||||
```bash
|
||||
@@ -128,6 +144,9 @@ bun run .agents/skills/freehire-search/cli/src/cli.ts search --category devops -
|
||||
# ML/AI roles anywhere, fully remote
|
||||
bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "machine learning" --category ml_ai --remote remote --format table
|
||||
|
||||
# Descriptions as plain text instead of Markdown
|
||||
bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "platform engineer" --limit 5 --description-format text
|
||||
|
||||
# Full details for a specific job
|
||||
bun run .agents/skills/freehire-search/cli/src/cli.ts detail golang-zensar-2bxu6dxm --format plain
|
||||
```
|
||||
@@ -136,14 +155,15 @@ bun run .agents/skills/freehire-search/cli/src/cli.ts detail golang-zensar-2bxu6
|
||||
|
||||
| Format | Best for |
|
||||
|--------|----------|
|
||||
| `json` | Default — programmatic use, passing a result's `id` (slug) to `detail` |
|
||||
| `json` | Default — programmatic use; the only format carrying each hit's description |
|
||||
| `table` | Quick human-readable scanning |
|
||||
| `plain` | Reading a single job's full detail (`detail` command) |
|
||||
|
||||
Search JSON is `{ "meta": { "count", "page", "total" }, "results": [...] }`; each
|
||||
result carries at least `id` (the freehire slug), `title`, `company`, `location`,
|
||||
`date`, and `url` (missing values are `null`). All errors are written to **stderr**
|
||||
as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
|
||||
`date`, `url`, and `description` (missing values are `null`). `table` and `plain`
|
||||
omit the description — it would swamp a scannable list. All errors are written to
|
||||
**stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
|
||||
|
||||
## Partial data
|
||||
|
||||
@@ -163,7 +183,7 @@ dictionaries never guess). So:
|
||||
|
||||
## Notes
|
||||
|
||||
- Data is from freehire.dev's public API — no credentials required. Only per-user
|
||||
- Data is from freehire.me's public API — no credentials required. Only per-user
|
||||
tracking (apply/save) needs a key, and this skill deliberately does not touch it:
|
||||
it is **search + detail only**.
|
||||
- `id` in search results is the freehire `public_slug` — pass it as-is to `detail`.
|
||||
@@ -172,3 +192,6 @@ dictionaries never guess). So:
|
||||
live values (with counts) for a query before filtering.
|
||||
- The API retries 429/5xx with exponential backoff; an unreachable API exits
|
||||
non-zero with a clear message (best-effort service, see the dependency note above).
|
||||
- `search` calls `/api/v1/agent/jobs/search` (public, like the rest). A self-hosted
|
||||
instance older than that endpoint answers 404, and the CLI reports it as an error
|
||||
naming the endpoint — never as an empty result set.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# freehire-cli
|
||||
|
||||
CLI for searching the [freehire.dev](https://freehire.dev) job aggregator across
|
||||
CLI for searching the [freehire.me](https://freehire.me) job aggregator across
|
||||
**many markets** (tech-focused), via its public JSON API.
|
||||
|
||||
**Data source**: freehire.dev REST API (`/api/v1/jobs/search`, `/api/v1/jobs/facets`, `/api/v1/jobs/{slug}`).
|
||||
**Data source**: freehire.me REST API (`/api/v1/agent/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.dev, a personal
|
||||
> **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_URL`
|
||||
@@ -25,7 +25,7 @@ The CLI runs without any install because it has zero runtime dependencies.
|
||||
|
||||
## Self-hosting / base URL
|
||||
|
||||
The base URL defaults to `https://freehire.dev` and is overridable with an env var:
|
||||
The base URL defaults to `https://freehire.me` and is overridable with an env var:
|
||||
|
||||
```bash
|
||||
FREEHIRE_API_URL=http://localhost:8080 bun run src/cli.ts search -q "go"
|
||||
@@ -44,6 +44,11 @@ Compose (`make up` → API on `:8080`, same `/api/v1/...` paths).
|
||||
`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`.
|
||||
|
||||
`search` hits the API's agent endpoint, so every JSON result already carries the
|
||||
posting's **full** description (Markdown by default, `--description-format
|
||||
text|html` to change it). `detail` remains for looking a single posting up by
|
||||
slug — including a closed one, which search does not return.
|
||||
|
||||
## Quick examples
|
||||
|
||||
```bash
|
||||
@@ -80,8 +85,9 @@ See `../SKILL.md` for the full flag reference and the hosted-dependency note.
|
||||
| `--remote` | | `remote` \| `hybrid` \| `onsite` (`work_mode`). |
|
||||
| `--facet` | | Any other facet as `key=value` (repeatable). |
|
||||
| `--format` | | `json` \| `table` \| `plain`. |
|
||||
| `--description-format` | | `markdown` (default) \| `text` \| `html` — how each result's full description is rendered (`json` output only). |
|
||||
|
||||
Facet values come from freehire's controlled vocabularies. Discover the live
|
||||
values (with counts) for a market at
|
||||
[`/api/v1/jobs/facets`](https://freehire.dev/api/v1/jobs/facets), or narrow it,
|
||||
e.g. `https://freehire.dev/api/v1/jobs/facets?q=react`.
|
||||
[`/api/v1/jobs/facets`](https://freehire.me/api/v1/jobs/facets), or narrow it,
|
||||
e.g. `https://freehire.me/api/v1/jobs/facets?q=react`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "freehire-cli",
|
||||
"version": "1.0.0",
|
||||
"description": "CLI for searching the freehire.dev job aggregator's public JSON API across many markets (tech-focused) — no authentication, zero runtime dependencies. Base URL is swappable via FREEHIRE_API_URL for self-hosting.",
|
||||
"description": "CLI for searching the freehire.me job aggregator's public JSON API across many markets (tech-focused) — no authentication, zero runtime dependencies. Base URL is swappable via FREEHIRE_API_URL for self-hosting.",
|
||||
"type": "module",
|
||||
"main": "src/cli.ts",
|
||||
"bin": {
|
||||
@@ -15,6 +15,6 @@
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
"@types/bun": "latest"
|
||||
"@types/bun": "1.3.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env bun
|
||||
// Self-contained CLI for searching the freehire.dev aggregator's public JSON API.
|
||||
// Self-contained CLI for searching the freehire.me aggregator's public JSON API.
|
||||
// No external CLI framework and zero runtime dependencies, so it runs anywhere
|
||||
// `bun` is available with nothing installed beyond the repo clone.
|
||||
//
|
||||
// Hosted-service dependency: reads are public (no API key), but they hit
|
||||
// freehire.dev — a personal project maintained best-effort (no formal SLA). Point
|
||||
// freehire.me — a personal project maintained best-effort (no formal SLA). Point
|
||||
// FREEHIRE_API_URL at a self-hosted freehire backend to swap the source.
|
||||
|
||||
import { runSearch, type SearchOpts } from "./commands/search.js"
|
||||
import { runSearch, DESCRIPTION_FORMATS, type DescriptionFormat, type SearchOpts } from "./commands/search.js"
|
||||
import { runDetail, type DetailOpts } from "./commands/detail.js"
|
||||
import { baseUrl } from "./helpers.js"
|
||||
|
||||
@@ -69,7 +69,7 @@ function commaList(raw: FlagValue): string[] {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const HELP = `freehire-cli — search the freehire.dev job aggregator (many markets, tech-focused)
|
||||
const HELP = `freehire-cli — search the freehire.me job aggregator (many markets, tech-focused)
|
||||
|
||||
USAGE
|
||||
bun run src/cli.ts search [-q "<keywords>"] [facet flags] [--format json|table|plain]
|
||||
@@ -81,8 +81,10 @@ SEARCH FLAGS
|
||||
--page <n> 1-indexed page. Default 1.
|
||||
--limit, -n <n> Results per page (API limit). Default 25.
|
||||
--format <fmt> json (default) | table | plain.
|
||||
--description-format markdown (default) | text | html — how each result's
|
||||
full description is rendered (json output only).
|
||||
|
||||
FACET FILTERS (values from freehire.dev's controlled vocabularies; comma = OR)
|
||||
FACET FILTERS (values from freehire.me's controlled vocabularies; comma = OR)
|
||||
--region <codes> Macro-region: global, eu, us, apac, latam, cis, ... e.g. --region eu,us
|
||||
--country <codes> ISO-3166 alpha-2, e.g. --country DE,GB
|
||||
--city <names> City name(s), e.g. --city Berlin
|
||||
@@ -95,7 +97,7 @@ FACET FILTERS (values from freehire.dev's controlled vocabularies; comma = OR)
|
||||
|
||||
DETAIL
|
||||
<slug|url> A freehire public slug (from a search result's id/slug)
|
||||
or a full https://freehire.dev/jobs/<slug> URL.
|
||||
or a full https://freehire.me/jobs/<slug> URL.
|
||||
|
||||
EXAMPLES
|
||||
bun run src/cli.ts search -q "backend engineer" --seniority senior --limit 10 --format table
|
||||
@@ -129,6 +131,18 @@ async function main(): Promise<number> {
|
||||
if (cmd === "search") {
|
||||
const fmt = (flags.format as string) || "json"
|
||||
|
||||
// Validated here rather than server-side: the API answers an unrecognized
|
||||
// format with raw HTML instead of an error, so a typo would silently change
|
||||
// the output rather than fail.
|
||||
const descFmt = stringFlag(flags["description-format"]) ?? "markdown"
|
||||
if (!DESCRIPTION_FORMATS.includes(descFmt as DescriptionFormat)) {
|
||||
const supported = DESCRIPTION_FORMATS.join("|")
|
||||
process.stderr.write(
|
||||
JSON.stringify({ error: `--description-format must be one of ${supported}, got "${descFmt}"`, code: "BAD_ARG" }) + "\n",
|
||||
)
|
||||
return 1
|
||||
}
|
||||
|
||||
for (const name of ["jobage", "page", "limit"] as const) {
|
||||
if (flags[name] !== undefined) {
|
||||
const v = parseIntFlag(name, flags[name])
|
||||
@@ -157,6 +171,7 @@ async function main(): Promise<number> {
|
||||
page: flags.page ? Math.max(1, parseInt(flags.page as string, 10)) : 1,
|
||||
limit: flags.limit ? Math.max(1, parseInt(flags.limit as string, 10)) : 25,
|
||||
format: (["json", "table", "plain"].includes(fmt) ? fmt : "json") as SearchOpts["format"],
|
||||
descriptionFormat: descFmt as DescriptionFormat,
|
||||
regions: commaList(flags.region),
|
||||
countries: commaList(flags.country),
|
||||
cities: commaList(flags.city),
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
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[]
|
||||
@@ -25,6 +37,10 @@ function buildQuery(opts: SearchOpts): URLSearchParams {
|
||||
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)
|
||||
@@ -90,11 +106,19 @@ function renderPlain(rows: JobResult[]): string {
|
||||
|
||||
export async function runSearch(opts: SearchOpts): Promise<number> {
|
||||
try {
|
||||
const env = await apiGet<FreehireJob[]>(`/api/v1/jobs/search?${buildQuery(opts).toString()}`)
|
||||
// The search endpoint returns an envelope; a null (404) is treated as empty.
|
||||
const jobs = env?.data ?? []
|
||||
const rows = jobs.map(toResult)
|
||||
const total = env?.meta?.total ?? rows.length
|
||||
const env = await apiGet<FreehireJob[]>(`${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")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Data source: the freehire.dev public REST API (JSON, `{data, meta}` envelope).
|
||||
// Data source: the freehire.me public REST API (JSON, `{data, meta}` envelope).
|
||||
// Reads are unauthenticated — no API key, the same bar as linkedin-search — and
|
||||
// unlike the HTML-scraping portals there is no markup to parse: we fetch JSON and
|
||||
// reshape it into the portal-skill contract's result fields. The base URL is
|
||||
// swappable via FREEHIRE_API_URL for self-hosting.
|
||||
|
||||
export const DEFAULT_BASE_URL = "https://freehire.dev"
|
||||
export const DEFAULT_BASE_URL = "https://freehire.me"
|
||||
|
||||
/** API base URL: FREEHIRE_API_URL (for a self-hosted instance) or the default. */
|
||||
export function baseUrl(): string {
|
||||
@@ -16,7 +16,7 @@ export function writeError(error: string, code: string): void {
|
||||
process.stderr.write(JSON.stringify({ error, code }) + "\n")
|
||||
}
|
||||
|
||||
const UA = "freehire-search-skill/1.0 (+https://freehire.dev)"
|
||||
const UA = "freehire-search-skill/1.0 (+https://freehire.me)"
|
||||
|
||||
/** The shared API response envelope: {data, meta, error}. */
|
||||
export interface Envelope<T> {
|
||||
@@ -114,6 +114,10 @@ export interface FreehireJob {
|
||||
* A search result in the portal-skill contract shape. `id` is the public_slug
|
||||
* (what `detail <slug>` consumes) and `date` is the posting date; missing values
|
||||
* are `null`, never omitted. The extra facet fields are a permitted superset.
|
||||
*
|
||||
* `description` is the posting's full text in the format the search asked the API
|
||||
* for — the agent search endpoint hydrates it server-side, so it arrives already
|
||||
* rendered and is passed through verbatim rather than run through `cleanHtml`.
|
||||
*/
|
||||
export interface JobResult {
|
||||
id: string
|
||||
@@ -127,6 +131,7 @@ export interface JobResult {
|
||||
regions: string[]
|
||||
countries: string[]
|
||||
skills: string[]
|
||||
description: string | null
|
||||
}
|
||||
|
||||
/** A job detail: the search result plus the cleaned description and enrichment. */
|
||||
@@ -153,6 +158,7 @@ export function toResult(j: FreehireJob): JobResult {
|
||||
regions: j.regions,
|
||||
countries: j.countries,
|
||||
skills: j.skills,
|
||||
description: j.description || null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,16 @@ describe("freehire CLI flag validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("--description-format validation", () => {
|
||||
test("an unsupported format exits 1 with BAD_ARG", async () => {
|
||||
const result = await runCLI(["search", "--description-format", "tekst"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
const err = parsedStderr(result.stderr);
|
||||
expect(err.code).toBe("BAD_ARG");
|
||||
expect(err.error).toMatch(/description-format/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--facet validation", () => {
|
||||
test("a facet without '=' exits 1 with BAD_ARG", async () => {
|
||||
const result = await runCLI(["search", "--facet", "novalue"]);
|
||||
|
||||
@@ -15,12 +15,32 @@ function captureStdout(): { get: () => string } {
|
||||
return { get: () => buf };
|
||||
}
|
||||
|
||||
function mockFetch(status: number, body: unknown): void {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
||||
/** Stub fetch with a canned response; the return value exposes the URL it was called with. */
|
||||
function mockFetch(status: number, body: unknown): { url: () => string } {
|
||||
let requested = "";
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
requested = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
});
|
||||
}) as typeof fetch;
|
||||
return { url: () => requested };
|
||||
}
|
||||
|
||||
/** The query params of the URL the mocked fetch was called with. */
|
||||
function requestedParams(mock: { url: () => string }): URLSearchParams {
|
||||
return new URL(mock.url()).searchParams;
|
||||
}
|
||||
|
||||
function captureStderr(): { get: () => string; restore: () => void } {
|
||||
let buf = "";
|
||||
const original = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
buf += chunk.toString();
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
return { get: () => buf, restore: () => (process.stderr.write = original) };
|
||||
}
|
||||
|
||||
function job(overrides: Partial<FreehireJob> = {}): FreehireJob {
|
||||
@@ -56,6 +76,7 @@ const searchOpts = {
|
||||
page: 1,
|
||||
limit: 25,
|
||||
format: "json" as const,
|
||||
descriptionFormat: "markdown" as const,
|
||||
regions: [] as string[],
|
||||
countries: [] as string[],
|
||||
cities: [] as string[],
|
||||
@@ -80,6 +101,61 @@ describe("runSearch (mocked fetch)", () => {
|
||||
expect(parsed.results[0].date).toBe("2026-07-06T00:00:00Z");
|
||||
});
|
||||
|
||||
test("queries the agent endpoint asking for full descriptions", async () => {
|
||||
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
|
||||
captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, query: "backend" });
|
||||
|
||||
expect(new URL(mock.url()).pathname).toBe("/api/v1/agent/jobs/search");
|
||||
expect(requestedParams(mock).get("include_description")).toBe("true");
|
||||
expect(requestedParams(mock).get("description_format")).toBe("markdown");
|
||||
});
|
||||
|
||||
test("asks for the requested description format", async () => {
|
||||
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
|
||||
captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, descriptionFormat: "text", query: "backend" });
|
||||
|
||||
expect(requestedParams(mock).get("description_format")).toBe("text");
|
||||
});
|
||||
|
||||
test("carries each hit's description verbatim, in the server's format", async () => {
|
||||
const markdown = "## About the role\n\n- Write Go\n- Ship things";
|
||||
mockFetch(200, { data: [job({ description: markdown })], meta: { total: 1 } });
|
||||
const out = captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, query: "backend" });
|
||||
|
||||
expect(JSON.parse(out.get()).results[0].description).toBe(markdown);
|
||||
});
|
||||
|
||||
test("a hit with no description carries null, not an empty string", async () => {
|
||||
mockFetch(200, { data: [job({ description: "" })], meta: { total: 1 } });
|
||||
const out = captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, query: "backend" });
|
||||
|
||||
expect(JSON.parse(out.get()).results[0].description).toBeNull();
|
||||
});
|
||||
|
||||
// A self-hosted freehire predating /agent/jobs/search answers 404, which apiGet
|
||||
// maps to null. Reporting that as "no results" would hide a broken endpoint
|
||||
// behind an empty, plausible-looking result set.
|
||||
test("a 404 from the search endpoint is an error, not an empty result set", async () => {
|
||||
mockFetch(404, { error: "not found" });
|
||||
const err = captureStderr();
|
||||
const out = captureStdout();
|
||||
|
||||
const code = await runSearch({ ...searchOpts, query: "backend" });
|
||||
err.restore();
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(out.get()).toBe("");
|
||||
expect(JSON.parse(err.get()).error).toMatch(/agent\/jobs\/search/);
|
||||
});
|
||||
|
||||
test("empty result set yields an empty results array", async () => {
|
||||
mockFetch(200, { data: [], meta: { total: 0 } });
|
||||
const out = captureStdout();
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("normalizeSlug", () => {
|
||||
expect(normalizeSlug("golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm");
|
||||
});
|
||||
test("extracts the slug from a /jobs/<slug> URL", () => {
|
||||
expect(normalizeSlug("https://freehire.dev/jobs/golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm");
|
||||
expect(normalizeSlug("https://freehire.me/jobs/golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm");
|
||||
});
|
||||
test("rejects a non-slug string", () => {
|
||||
expect(normalizeSlug("not a slug!")).toBeNull();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { apiGet } from "../src/helpers";
|
||||
|
||||
// The portal contract requires backoff on 429/5xx. These tests pin the retry
|
||||
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
|
||||
// fires immediately so the exhaustion case does not sleep through the real
|
||||
// 500ms -> 8s backoff schedule. apiGet's documented graceful-degradation
|
||||
// contract (connection failures fail fast, no retry) is pinned too.
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
});
|
||||
|
||||
function instantTimers() {
|
||||
globalThis.setTimeout = ((fn: () => void) =>
|
||||
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
|
||||
}
|
||||
|
||||
function stubFetch(responses: Array<() => Response>): { calls: number } {
|
||||
const state = { calls: 0 };
|
||||
globalThis.fetch = (async () => {
|
||||
const i = Math.min(state.calls, responses.length - 1);
|
||||
state.calls++;
|
||||
return responses[i]();
|
||||
}) as unknown as typeof fetch;
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("apiGet retry/backoff", () => {
|
||||
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([
|
||||
() => new Response("", { status: 429 }),
|
||||
() => new Response('{"data":[]}', { status: 200 }),
|
||||
]);
|
||||
|
||||
const envelope = await apiGet<unknown[]>("/x");
|
||||
expect(envelope).not.toBeNull();
|
||||
expect(state.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("returns the documented null on 404 without retrying", async () => {
|
||||
const state = stubFetch([() => new Response("", { status: 404 })]);
|
||||
|
||||
const envelope = await apiGet("/x");
|
||||
expect(envelope).toBeNull();
|
||||
expect(state.calls).toBe(1);
|
||||
});
|
||||
|
||||
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([() => new Response("", { status: 500 })]);
|
||||
|
||||
await expect(apiGet("/x")).rejects.toThrow(/500/);
|
||||
expect(state.calls).toBe(7);
|
||||
});
|
||||
|
||||
test("fails fast on a connection error - no retry, per the graceful-degradation contract", async () => {
|
||||
const state = { calls: 0 };
|
||||
globalThis.fetch = (async () => {
|
||||
state.calls++;
|
||||
throw new TypeError("Unable to connect");
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await expect(apiGet("/x")).rejects.toThrow(/could not reach the freehire API/);
|
||||
expect(state.calls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
# freehire.dev API reference
|
||||
# freehire.me API reference
|
||||
|
||||
The endpoints, parameters, and response shapes this skill depends on. This is the
|
||||
file to update if the freehire API changes. Base URL defaults to
|
||||
`https://freehire.dev` and is overridable via the `FREEHIRE_API_URL` env var.
|
||||
`https://freehire.me` and is overridable via the `FREEHIRE_API_URL` env var.
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -14,7 +14,8 @@ Verified against the live API:
|
||||
|
||||
| Endpoint | Status |
|
||||
|----------|--------|
|
||||
| `GET /api/v1/jobs/search` | 200 |
|
||||
| `GET /api/v1/agent/jobs/search` | 200 |
|
||||
| `GET /api/v1/jobs/search` | 200 (the web variant; not used by this skill) |
|
||||
| `GET /api/v1/jobs/facets` | 200 |
|
||||
| `GET /api/v1/jobs/{slug}` | 200 |
|
||||
| `GET /api/v1/auth/me` | 401 (auth required — not used here) |
|
||||
@@ -26,10 +27,37 @@ array in `data` and pagination in `meta` (`{ total, limit, offset }`); a single
|
||||
item puts the object in `data`. Errors are `{ "error": "<message>" }` with a 4xx/5xx
|
||||
status (e.g. 404 → `{ "error": "not found" }`).
|
||||
|
||||
## `GET /api/v1/agent/jobs/search`
|
||||
|
||||
The endpoint the skill's `search` command uses. Full-text + facet search over open
|
||||
jobs, returning `data: [job, …]` with `meta.total` = the estimated match count.
|
||||
|
||||
It runs the **same query** as the web-facing `/api/v1/jobs/search` — same `q`, same
|
||||
facets, same ranking, same pagination guard (`offset + limit ≤ 10000`) — and differs
|
||||
in one respect: asked to, it replaces the search index's truncated `description`
|
||||
preview with the posting's **full** description read from the database. That is what
|
||||
lets a search of N roles stay one request instead of N + 1.
|
||||
|
||||
Two extra parameters control it:
|
||||
|
||||
| Param | Maps to CLI flag | Notes |
|
||||
|-------|------------------|-------|
|
||||
| `include_description` | (always `true`) | Without it the endpoint serves the index preview, same as the web search. |
|
||||
| `description_format` | `--description-format` | `markdown` (the skill's default), `text`, or `html`. **An unrecognized value is not an error** — the API falls back to `html`, so the CLI validates the flag itself. |
|
||||
|
||||
Hydration is best-effort per hit: a result whose row has vanished from the database
|
||||
(the index lagging a just-removed job) keeps the preview rather than being dropped,
|
||||
so `description` is a full text in practice but never guaranteed to be.
|
||||
|
||||
A `404` from this path means the instance predates the endpoint (a self-hosted
|
||||
freehire behind `FREEHIRE_API_URL`), not a missing job; the CLI reports it as an
|
||||
error naming the path rather than as an empty result set.
|
||||
|
||||
## `GET /api/v1/jobs/search`
|
||||
|
||||
Full-text + facet search over open jobs. Returns `data: [job, …]` with
|
||||
`meta.total` = the total match count.
|
||||
The web variant of the same search — identical query surface, but `description` is
|
||||
always the index's truncated preview. The skill does not call it; it is listed here
|
||||
because the shared parameters below are documented against both.
|
||||
|
||||
Query parameters used by the skill:
|
||||
|
||||
@@ -66,7 +94,8 @@ bounded server-side (`offset + limit ≤ 10000`).
|
||||
"company": "Zensar",
|
||||
"company_slug": "zensar",
|
||||
"location": "India", // free-text ATS location
|
||||
"description": "<ul><li>…</li></ul>", // HTML; the skill strips it for detail
|
||||
"description": "- …", // agent search: full text in the requested
|
||||
// format; elsewhere HTML, stripped client-side
|
||||
"skills": ["go", "kubernetes", …], // dictionary facet (top-level)
|
||||
"work_mode": "remote", // may be absent
|
||||
"regions": ["apac"], // dictionary/hybrid facet
|
||||
@@ -105,8 +134,10 @@ points users to (`?q=<role>` scopes the counts). Example:
|
||||
## Parsing notes
|
||||
|
||||
- The response is JSON, so there is no HTML card parsing (unlike the scraping
|
||||
portals). The only markup handling is stripping the `description`'s HTML into
|
||||
readable text (`cleanHtml` in `cli/src/helpers.ts`).
|
||||
portals). The only markup handling left client-side is `detail`'s: `/jobs/{slug}`
|
||||
serves HTML, which `cleanHtml` (`cli/src/helpers.ts`) strips into readable text.
|
||||
Search descriptions arrive already rendered by the API and are passed through
|
||||
verbatim — stripping them again would undo the Markdown structure.
|
||||
- Fetch uses a browser-ish User-Agent, `Accept: application/json`, and exponential
|
||||
backoff with jitter on 429/5xx (max 6 retries). A connection error (API
|
||||
unreachable) fails fast with a clear message — no retry, since it is not
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bunli/core": "latest",
|
||||
"@bunli/utils": "latest",
|
||||
"@bunli/core": "0.9.1",
|
||||
"@bunli/utils": "0.6.0",
|
||||
"node-html-parser": "^6.1.13",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/bun": "1.3.14",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { fetchWithUA } from "../src/helpers";
|
||||
|
||||
// The portal contract requires backoff on 429/5xx. These tests pin the retry
|
||||
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
|
||||
// fires immediately so the exhaustion case does not sleep through the real
|
||||
// 500ms -> 5s backoff schedule.
|
||||
//
|
||||
// fetchWithUA deliberately RETURNS non-retry statuses instead of throwing -
|
||||
// callers own 4xx handling (e.g. rssFetch's Cloudflare 403 message). The 4xx
|
||||
// test pins that contract.
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
});
|
||||
|
||||
function instantTimers() {
|
||||
globalThis.setTimeout = ((fn: () => void) =>
|
||||
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
|
||||
}
|
||||
|
||||
function stubFetch(responses: Array<() => Response>): { calls: number } {
|
||||
const state = { calls: 0 };
|
||||
globalThis.fetch = (async () => {
|
||||
const i = Math.min(state.calls, responses.length - 1);
|
||||
state.calls++;
|
||||
return responses[i]();
|
||||
}) as unknown as typeof fetch;
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("fetchWithUA retry/backoff", () => {
|
||||
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([
|
||||
() => new Response("", { status: 429 }),
|
||||
() => new Response("ok", { status: 200 }),
|
||||
]);
|
||||
|
||||
const response = await fetchWithUA("https://jobbank.dk/x");
|
||||
expect(response.status).toBe(200);
|
||||
expect(state.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("returns a plain 4xx to the caller without retrying", async () => {
|
||||
const state = stubFetch([() => new Response("", { status: 403 })]);
|
||||
|
||||
const response = await fetchWithUA("https://jobbank.dk/x");
|
||||
expect(response.status).toBe(403);
|
||||
expect(state.calls).toBe(1);
|
||||
});
|
||||
|
||||
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([() => new Response("", { status: 500 })]);
|
||||
|
||||
await expect(fetchWithUA("https://jobbank.dk/x")).rejects.toThrow(/500/);
|
||||
expect(state.calls).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -13,13 +13,13 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bunli/core": "latest",
|
||||
"@bunli/utils": "latest",
|
||||
"@bunli/core": "0.9.1",
|
||||
"@bunli/utils": "0.6.0",
|
||||
"node-html-parser": "^6.1.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
"@types/bun": "latest"
|
||||
"@types/bun": "1.3.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { apiFetch, apiPost } from "../src/helpers";
|
||||
|
||||
// The portal contract requires backoff on 429/5xx. These tests pin the retry
|
||||
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
|
||||
// fires immediately so the exhaustion case does not sleep through the real
|
||||
// 500ms -> 5s backoff schedule. apiFetch and apiPost carry separate copies of
|
||||
// the loop, so both are exercised to keep them from drifting apart.
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
});
|
||||
|
||||
function instantTimers() {
|
||||
globalThis.setTimeout = ((fn: () => void) =>
|
||||
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
|
||||
}
|
||||
|
||||
function stubFetch(responses: Array<() => Response>): { calls: number } {
|
||||
const state = { calls: 0 };
|
||||
globalThis.fetch = (async () => {
|
||||
const i = Math.min(state.calls, responses.length - 1);
|
||||
state.calls++;
|
||||
return responses[i]();
|
||||
}) as unknown as typeof fetch;
|
||||
return state;
|
||||
}
|
||||
|
||||
const wrappers: Array<[string, () => Promise<{ ok: boolean }>]> = [
|
||||
["apiFetch", () => apiFetch<{ ok: boolean }>("/x")],
|
||||
["apiPost", () => apiPost<{ ok: boolean }>("/x", {})],
|
||||
];
|
||||
|
||||
for (const [name, call] of wrappers) {
|
||||
describe(`${name} retry/backoff`, () => {
|
||||
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([
|
||||
() => new Response("", { status: 429 }),
|
||||
() => new Response('{"ok":true}', { status: 200 }),
|
||||
]);
|
||||
|
||||
const data = await call();
|
||||
expect(data.ok).toBe(true);
|
||||
expect(state.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("does not retry a plain 4xx", async () => {
|
||||
const state = stubFetch([() => new Response("", { status: 400 })]);
|
||||
|
||||
await expect(call()).rejects.toThrow(/400/);
|
||||
expect(state.calls).toBe(1);
|
||||
});
|
||||
|
||||
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([() => new Response("", { status: 500 })]);
|
||||
|
||||
await expect(call()).rejects.toThrow(/500/);
|
||||
expect(state.calls).toBe(7);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -13,13 +13,13 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bunli/core": "latest",
|
||||
"@bunli/utils": "latest",
|
||||
"@bunli/core": "0.9.1",
|
||||
"@bunli/utils": "0.6.0",
|
||||
"node-html-parser": "^6.1.13",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
"@types/bun": "latest"
|
||||
"@types/bun": "1.3.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { apiFetch, htmlFetch } from "../src/helpers";
|
||||
|
||||
// The portal contract requires backoff on 429/5xx. These tests pin the retry
|
||||
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
|
||||
// fires immediately so the exhaustion case does not sleep through the real
|
||||
// 500ms -> 5s backoff schedule. apiFetch and htmlFetch carry separate copies
|
||||
// of the loop, so both are exercised to keep them from drifting apart.
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
});
|
||||
|
||||
function instantTimers() {
|
||||
globalThis.setTimeout = ((fn: () => void) =>
|
||||
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
|
||||
}
|
||||
|
||||
function stubFetch(responses: Array<() => Response>): { calls: number } {
|
||||
const state = { calls: 0 };
|
||||
globalThis.fetch = (async () => {
|
||||
const i = Math.min(state.calls, responses.length - 1);
|
||||
state.calls++;
|
||||
return responses[i]();
|
||||
}) as unknown as typeof fetch;
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("htmlFetch retry/backoff", () => {
|
||||
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([
|
||||
() => new Response("", { status: 429 }),
|
||||
() => new Response("<html>ok</html>", { status: 200 }),
|
||||
]);
|
||||
|
||||
const html = await htmlFetch("https://www.jobindex.dk/x");
|
||||
expect(html).toContain("ok");
|
||||
expect(state.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("does not retry a plain 4xx", async () => {
|
||||
const state = stubFetch([() => new Response("", { status: 400 })]);
|
||||
|
||||
await expect(htmlFetch("https://www.jobindex.dk/x")).rejects.toThrow(/400/);
|
||||
expect(state.calls).toBe(1);
|
||||
});
|
||||
|
||||
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([() => new Response("", { status: 500 })]);
|
||||
|
||||
await expect(htmlFetch("https://www.jobindex.dk/x")).rejects.toThrow(/500/);
|
||||
expect(state.calls).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiFetch retry/backoff", () => {
|
||||
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([
|
||||
() => new Response("", { status: 429 }),
|
||||
() => new Response('{"ok":true}', { status: 200 }),
|
||||
]);
|
||||
|
||||
const data = await apiFetch<{ ok: boolean }>("/x");
|
||||
expect(data.ok).toBe(true);
|
||||
expect(state.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("does not retry a plain 4xx", async () => {
|
||||
const state = stubFetch([() => new Response("", { status: 400 })]);
|
||||
|
||||
await expect(apiFetch("/x")).rejects.toThrow(/400/);
|
||||
expect(state.calls).toBe(1);
|
||||
});
|
||||
|
||||
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([() => new Response("", { status: 500 })]);
|
||||
|
||||
await expect(apiFetch("/x")).rejects.toThrow(/500/);
|
||||
expect(state.calls).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -13,12 +13,12 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bunli/core": "latest",
|
||||
"@bunli/utils": "latest",
|
||||
"@bunli/core": "0.9.1",
|
||||
"@bunli/utils": "0.6.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
"@types/bun": "latest"
|
||||
"@types/bun": "1.3.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { apiFetch } from "../src/helpers";
|
||||
|
||||
// The portal contract requires backoff on 429/5xx. These tests pin the retry
|
||||
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
|
||||
// fires immediately so the exhaustion case does not sleep through the real
|
||||
// 500ms -> 5s backoff schedule.
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
});
|
||||
|
||||
function instantTimers() {
|
||||
globalThis.setTimeout = ((fn: () => void) =>
|
||||
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
|
||||
}
|
||||
|
||||
function stubFetch(responses: Array<() => Response>): { calls: number } {
|
||||
const state = { calls: 0 };
|
||||
globalThis.fetch = (async () => {
|
||||
const i = Math.min(state.calls, responses.length - 1);
|
||||
state.calls++;
|
||||
return responses[i]();
|
||||
}) as unknown as typeof fetch;
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("apiFetch retry/backoff", () => {
|
||||
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([
|
||||
() => new Response("", { status: 429 }),
|
||||
() => new Response('{"ok":true}', { status: 200 }),
|
||||
]);
|
||||
|
||||
const data = await apiFetch<{ ok: boolean }>("/x");
|
||||
expect(data.ok).toBe(true);
|
||||
expect(state.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("does not retry a plain 4xx", async () => {
|
||||
const state = stubFetch([() => new Response("", { status: 400 })]);
|
||||
|
||||
await expect(apiFetch("/x")).rejects.toThrow(/400/);
|
||||
expect(state.calls).toBe(1);
|
||||
});
|
||||
|
||||
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([() => new Response("", { status: 500 })]);
|
||||
|
||||
await expect(apiFetch("/x")).rejects.toThrow(/500/);
|
||||
expect(state.calls).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,6 @@
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
"@types/bun": "latest"
|
||||
"@types/bun": "1.3.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { htmlFetch } from "../src/helpers";
|
||||
|
||||
// The portal contract requires backoff on 429/5xx. These tests pin the retry
|
||||
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
|
||||
// fires immediately so the exhaustion case does not sleep through the real
|
||||
// 500ms -> 8s backoff schedule.
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
});
|
||||
|
||||
function instantTimers() {
|
||||
globalThis.setTimeout = ((fn: () => void) =>
|
||||
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
|
||||
}
|
||||
|
||||
function stubFetch(responses: Array<() => Response>): { calls: number } {
|
||||
const state = { calls: 0 };
|
||||
globalThis.fetch = (async () => {
|
||||
const i = Math.min(state.calls, responses.length - 1);
|
||||
state.calls++;
|
||||
return responses[i]();
|
||||
}) as unknown as typeof fetch;
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("htmlFetch retry/backoff", () => {
|
||||
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([
|
||||
() => new Response("", { status: 429 }),
|
||||
() => new Response("<html>ok</html>", { status: 200 }),
|
||||
]);
|
||||
|
||||
const html = await htmlFetch("https://www.linkedin.com/x");
|
||||
expect(html).toContain("ok");
|
||||
expect(state.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("returns the documented empty string on 404 without retrying", async () => {
|
||||
const state = stubFetch([() => new Response("", { status: 404 })]);
|
||||
|
||||
const html = await htmlFetch("https://www.linkedin.com/x");
|
||||
expect(html).toBe("");
|
||||
expect(state.calls).toBe(1);
|
||||
});
|
||||
|
||||
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([() => new Response("", { status: 500 })]);
|
||||
|
||||
await expect(htmlFetch("https://www.linkedin.com/x")).rejects.toThrow(/500/);
|
||||
expect(state.calls).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
# /add-template - Register a Custom CV or Cover Letter Template
|
||||
|
||||
You are helping the user register their own LaTeX template with the AI Job Search framework. The framework ships with moderncv (banking style) for CVs and a custom `cover.cls` for cover letters. This command lets the user swap in their own template: store the template files, capture usage instructions (compile engine, fonts, style rules, page limits), verify the template compiles, and wire it into the `/apply` workflow so every future application uses it.
|
||||
You are helping the user register their own CV or cover letter template with the AI Job Search framework — LaTeX, Typst, or any other toolchain that compiles to PDF from the command line. The framework ships with moderncv (banking style) for CVs and a custom `cover.cls` for cover letters. This command lets the user swap in their own template: store the template files, capture usage instructions (source extension, compile command, fonts, style rules, page limits), verify the template compiles, and wire it into the `/apply` workflow so every future application uses it.
|
||||
|
||||
`$ARGUMENTS` may contain a subcommand, a file path, or nothing.
|
||||
|
||||
@@ -22,9 +22,9 @@ Use Glob with `templates/**/TEMPLATE.md` to find registered templates. For each,
|
||||
```
|
||||
## Registered Templates
|
||||
|
||||
| Name | Type | Engine | Fonts | Active |
|
||||
|------|------|--------|-------|--------|
|
||||
| <name> | CV / Cover letter | lualatex/xelatex/pdflatex | <main font> | yes/no |
|
||||
| Name | Type | Source | Toolchain | Fonts | Active |
|
||||
|------|------|--------|-----------|-------|--------|
|
||||
| <name> | CV / Cover letter | .tex/.typ/... | lualatex/typst/... | <main font> | yes/no |
|
||||
```
|
||||
|
||||
A template is **active** if `05-cv-templates.md` (CV) or `06-cover-letter-templates.md` (cover letter) contains an `ACTIVE-TEMPLATE` managed block naming it. If no custom templates exist, say so and explain that `/add-template` registers one. Stop here.
|
||||
@@ -39,14 +39,16 @@ If `$ARGUMENTS` contains `--use <name>`:
|
||||
4. If more than one manifest matches, stop and list the matching manifest paths. Ask the user to rename one of the templates; activation must be unambiguous.
|
||||
5. Read the matching `TEMPLATE.md` and extract:
|
||||
- **Type:** `CV` or `Cover letter`
|
||||
- **Engine:** `lualatex`, `xelatex`, or `pdflatex`
|
||||
- **Source extension:** e.g. `.tex`, `.typ`
|
||||
- **Compile command:** the full declared command
|
||||
- **Engine/toolchain:** e.g. `lualatex`, `typst` (display label)
|
||||
- **Page limit:** `<N> page(s)`
|
||||
- **Fonts:** the full font summary line
|
||||
6. Derive the template folder from the manifest path and verify `template.tex` exists in the same folder. If it is missing, stop with an error; the template registration is incomplete.
|
||||
6. Derive the template folder from the manifest path and verify `template<source-extension>` exists in the same folder. If it is missing, stop with an error; the template registration is incomplete.
|
||||
7. Derive `<type>` for Step 5 from the manifest path:
|
||||
- `templates/cv/<name>/TEMPLATE.md` -> `cv`
|
||||
- `templates/cover_letters/<name>/TEMPLATE.md` -> `cover_letters`
|
||||
8. Continue to Step 5 using the resolved `<name>`, `<type>`, `<engine>`, font summary, page limit, template skeleton path, and manifest path. Do not re-run Steps 1-4; `--use` switches an already-registered template.
|
||||
8. Continue to Step 5 using the resolved `<name>`, `<type>`, `<source-extension>`, `<compile-command>`, engine/toolchain label, font summary, page limit, template skeleton path, and manifest path. Do not re-run Steps 1-4; `--use` switches an already-registered template.
|
||||
|
||||
---
|
||||
|
||||
@@ -56,28 +58,32 @@ Ask the user (skip anything already answered by `$ARGUMENTS`):
|
||||
|
||||
1. **Type:** Is this a **CV** template or a **cover letter** template?
|
||||
2. **Source:** Where is the template? Accept any of:
|
||||
- A path or @-mention of a `.tex` file (plus optional `.cls`/`.sty` files)
|
||||
- Pasted LaTeX content
|
||||
- A directory containing the template and its assets (class files, fonts, images)
|
||||
- A path or @-mention of a source file in any toolchain (`.tex` plus optional `.cls`/`.sty`, `.typ` plus optional local packages, or another compile-to-PDF format)
|
||||
- Pasted template content
|
||||
- A directory containing the template and its assets (class/package files, fonts, images)
|
||||
|
||||
Read every provided file. If the template references a document class or package that is not part of standard TeX distributions (e.g. a custom `.cls`), confirm the user has the file and ask for it if missing — the template cannot compile without it.
|
||||
Read every provided file. If the template references an include the declared toolchain doesn't ship by default — a custom `.cls`/`.sty` not part of standard TeX distributions, a Typst package imported via a local `#import`, or an equivalent for another toolchain — confirm the user has the file and ask for it if missing — the template cannot compile without it.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Capture Template Instructions
|
||||
|
||||
Interview the user for the metadata that `/apply` needs to use the template correctly. Infer as much as possible from the LaTeX source first (documentclass, `\fontspec` calls, geometry, colors) and present your inferences for confirmation rather than asking blind questions.
|
||||
Interview the user for the metadata that `/apply` needs to use the template correctly. Infer as much as possible from the source first (LaTeX: documentclass, `\fontspec` calls, geometry, colors; Typst: `#set`/`#show` rules, `#import`s; other toolchains: whatever the format exposes) and present your inferences for confirmation rather than asking blind questions.
|
||||
|
||||
Collect:
|
||||
|
||||
1. **Name** - short kebab-case identifier (e.g. `awesome-cv`, `classic-serif`). Must not collide with an existing folder in `templates/`.
|
||||
2. **Compile engine** - `lualatex`, `xelatex`, or `pdflatex`. If the source uses `fontspec` or loads font files by path, it requires `xelatex` or `lualatex`; tell the user this rather than letting them pick `pdflatex`.
|
||||
3. **Fonts** - which font(s) the template uses and where they come from:
|
||||
- **Bundled font files** (`.ttf`/`.otf` shipped with the template): copy them into the template folder in Step 3 and record the relative `Path` used in `\fontspec` calls.
|
||||
- **System / TeX-distribution fonts**: record the font name and note that the user's machine must have it installed.
|
||||
4. **Style rules** - anything the drafter must preserve when filling the template: color scheme, section order, heading style, spacing conventions, bullet formatting, date format.
|
||||
5. **Page limit** - hard page count for the compiled PDF. Default: **2 pages** for a CV, **1 page** for a cover letter. `/apply`'s compile-and-inspect loop enforces this.
|
||||
6. **Known pitfalls** (optional) - macros that break with certain content (like the stock template's `\lettercontent{}`/`itemize` interaction), characters that need escaping, sections that must not be reordered.
|
||||
2. **Source extension** - the main file's extension (`.tex`, `.typ`, ...), inferred from the provided source file.
|
||||
3. **Compile command** - the full command `/apply` and Step 4's test compile will run, using `<file>` (no extension) as the placeholder for the output basename:
|
||||
- **`.tex` source**: infer the engine the same way as before - if the source uses `fontspec` or loads font files by path, it requires `xelatex` or `lualatex`; tell the user this rather than letting them pick `pdflatex`. Render as `lualatex -interaction=nonstopmode <file>.tex` (or the appropriate engine).
|
||||
- **`.typ` source**: default to `typst compile <file>.typ <file>.pdf` - Typst has a single binary, no engine choice.
|
||||
- **Anything else**: no built-in guidance; ask the user for the exact compile command.
|
||||
4. **Fonts** - which font(s) the template uses and where they come from:
|
||||
- **Bundled font files** (`.ttf`/`.otf` shipped with the template): copy them into the template folder in Step 3 and record the relative path used to load them (LaTeX `\fontspec` `Path`, Typst `#import`/font path, or equivalent).
|
||||
- **System / distribution fonts**: record the font name and note that the user's machine must have it installed.
|
||||
5. **Style rules** - anything the drafter must preserve when filling the template: color scheme, section order, heading style, spacing conventions, bullet formatting, date format.
|
||||
6. **Page limit** - hard page count for the compiled PDF. Default: **2 pages** for a CV, **1 page** for a cover letter. `/apply`'s compile-and-inspect loop enforces this.
|
||||
7. **Known pitfalls** (optional) - macros/rules that break with certain content (like the stock template's `\lettercontent{}`/`itemize` interaction), characters that need escaping, sections that must not be reordered.
|
||||
|
||||
---
|
||||
|
||||
@@ -90,23 +96,24 @@ Create the template folder:
|
||||
|
||||
Write into it:
|
||||
|
||||
1. **`template.tex`** - the template skeleton. Replace all personal data in the source with `[PLACEHOLDER]` tokens (`[YOUR_NAME]`, `[YOUR_EMAIL]`, `[YOUR_PHONE]`, `[YOUR_LINKEDIN_URL]`, ...) so the template is shareable and profile-agnostic. Keep the structure, preamble, and styling exactly as provided.
|
||||
2. **Class/style files** - copy any `.cls`/`.sty` files alongside `template.tex`.
|
||||
3. **`fonts/`** - copy bundled font files here, preserving any directory layout the `\fontspec` `Path` options expect. Adjust `Path` values in `template.tex` to be relative to the template folder.
|
||||
1. **`template<source-extension>`** (e.g. `template.tex`, `template.typ`) - the template skeleton. Replace all personal data in the source with `[PLACEHOLDER]` tokens (`[YOUR_NAME]`, `[YOUR_EMAIL]`, `[YOUR_PHONE]`, `[YOUR_LINKEDIN_URL]`, ...) so the template is shareable and profile-agnostic. Keep the structure, preamble, and styling exactly as provided.
|
||||
2. **Class/style/package files** - copy any companion files (`.cls`/`.sty` for LaTeX, local Typst packages, or equivalents) alongside the skeleton.
|
||||
3. **`fonts/`** - copy bundled font files here, preserving any directory layout the toolchain's font-loading mechanism expects (LaTeX `\fontspec` `Path`, Typst font path, ...). Adjust those path values in the skeleton to be relative to the template folder.
|
||||
4. **`TEMPLATE.md`** - the manifest. Use exactly this format:
|
||||
|
||||
```markdown
|
||||
# Template: <name>
|
||||
|
||||
- **Type:** CV | Cover letter
|
||||
- **Engine:** lualatex | xelatex | pdflatex
|
||||
- **Source extension:** .tex | .typ | ...
|
||||
- **Engine/toolchain:** lualatex | xelatex | pdflatex | typst | <other> (display label only)
|
||||
- **Page limit:** <N> page(s)
|
||||
- **Fonts:** <main font> (<bundled in fonts/ | system font - must be installed>)
|
||||
- **Class/packages:** <documentclass and any non-standard packages, or "standard">
|
||||
- **Class/packages:** <documentclass/imports and any non-standard packages, or "standard">
|
||||
|
||||
## Compile command
|
||||
|
||||
cd <output dir> && <engine> -interaction=nonstopmode <file>.tex
|
||||
cd <output dir> && <the full declared command, e.g. lualatex -interaction=nonstopmode <file>.tex or typst compile <file>.typ <file>.pdf>
|
||||
|
||||
## Style rules
|
||||
|
||||
@@ -122,16 +129,16 @@ Write into it:
|
||||
|
||||
## Step 4: Verify the Template Compiles (MANDATORY)
|
||||
|
||||
Never register a template without a successful test compile. LaTeX templates that "look fine" routinely fail on missing fonts, missing classes, or engine mismatches.
|
||||
Never register a template without a successful test compile. Templates that "look fine" routinely fail on missing fonts, missing classes/packages, or a wrong compile command.
|
||||
|
||||
1. Copy `template.tex` to a scratch file in the same folder (e.g. `_compile_test.tex`) and fill every `[PLACEHOLDER]` with realistic dummy data (name, contact line, one education entry, one job entry with 3 bullets — enough content to exercise the layout).
|
||||
2. Compile with the declared engine:
|
||||
1. Copy `template<source-extension>` to a scratch file in the same folder (e.g. `_compile_test.tex` or `_compile_test.typ`) and fill every `[PLACEHOLDER]` with realistic dummy data (name, contact line, one education entry, one job entry with 3 bullets — enough content to exercise the layout).
|
||||
2. Compile with the declared compile command, substituting `_compile_test` for `<file>`:
|
||||
```bash
|
||||
cd templates/<type>/<name> && <engine> -interaction=nonstopmode _compile_test.tex
|
||||
cd templates/<type>/<name> && <declared compile command with <file> -> _compile_test>
|
||||
```
|
||||
3. If the compile fails: show the user the relevant error lines, diagnose (missing font file, wrong engine, missing class), fix what you can (e.g. font `Path` values), and re-compile. If the fix needs input only the user has (a missing font file, a license-restricted class), ask for it and wait.
|
||||
4. On success, Read the PDF and confirm the layout renders sensibly (no overlapping text, fonts loaded, page count plausible for dummy content). Record any surprises in the manifest's "Known pitfalls".
|
||||
5. Delete the scratch files and generated artifacts for the test compile: `_compile_test.tex`, `_compile_test.pdf`, `_compile_test.aux`, `_compile_test.log`, `_compile_test.out`, `_compile_test.fls`, `_compile_test.fdb_latexmk`, `_compile_test.synctex.gz`, and any other `_compile_test.*` byproducts.
|
||||
3. If the compile fails: show the user the relevant error lines, diagnose (missing font file, wrong engine/command, missing class or package), fix what you can (e.g. font path values), and re-compile. If the fix needs input only the user has (a missing font file, a license-restricted class), ask for it and wait.
|
||||
4. On success, confirm a PDF was produced and Read it to check the layout renders sensibly (no overlapping text, fonts loaded, page count matches the declared page limit for the dummy content). Record any surprises in the manifest's "Known pitfalls".
|
||||
5. Delete the scratch source file, the scratch PDF, and any other intermediate files the compile command produced (LaTeX toolchains typically leave `_compile_test.aux`/`.log`/`.out`/`.fls`/`.fdb_latexmk`/`.synctex.gz`; other toolchains may leave nothing beyond the PDF — check what actually landed in the folder and remove all `_compile_test.*` byproducts).
|
||||
|
||||
Do not proceed to Step 5 until the test compile passes.
|
||||
|
||||
@@ -139,7 +146,7 @@ Do not proceed to Step 5 until the test compile passes.
|
||||
|
||||
## Step 5: Activate the Template
|
||||
|
||||
Activation wires the template into `/apply` by adding a **managed block** to the top of the relevant guidance file — `05-cv-templates.md` for CVs, `06-cover-letter-templates.md` for cover letters. `/apply` reads these files in its drafting step, so the block is all it takes.
|
||||
Activation wires the template into `/apply` by adding a **managed block** to the top of the relevant guidance file — `05-cv-templates.md` for CVs, `06-cover-letter-templates.md` for cover letters. `/apply` reads these files in both its drafting step and its compile step, so the block is all it takes.
|
||||
|
||||
If Step 5 was reached from Switch Mode, use the template metadata resolved from `TEMPLATE.md`. If Step 5 was reached after registering a new template, use the metadata collected and verified in Steps 2-4.
|
||||
|
||||
@@ -151,12 +158,13 @@ Insert (or replace, if one exists) this block immediately after the file's H1 ti
|
||||
>
|
||||
> A custom template is active. Where this block conflicts with the stock guidance below, this block wins. Structural advice below (tailoring, page-budget, cutting rules) still applies.
|
||||
>
|
||||
> - **Template skeleton:** `templates/<type>/<name>/template.tex` — use this as the structural reference instead of the stock template
|
||||
> - **Template skeleton:** `templates/<type>/<name>/template<source-extension>` — use this as the structural reference instead of the stock template
|
||||
> - **Manifest:** `templates/<type>/<name>/TEMPLATE.md` — read this for style rules and known pitfalls before drafting
|
||||
> - **Compile with:** `<engine>` (not the engine named in the stock guidance below)
|
||||
> - **Fonts:** <font summary, including any Path note for bundled fonts>
|
||||
> - **Source extension:** `<source-extension>` (not `.tex` unless the template's own toolchain is LaTeX)
|
||||
> - **Compile command:** `<the full declared command>` (not the command named in the stock guidance below — `/apply`'s compile step must use this instead)
|
||||
> - **Fonts:** <font summary, including any path note for bundled fonts>
|
||||
> - **Page limit:** exactly <N> page(s)
|
||||
> - **Output file:** unchanged (`cv/main_<company>_<role>.tex` / `cover_letters/cover_<company>_<role>.tex`); copy any class/font files the template needs into the output directory, or reference them by relative path
|
||||
> - **Output file:** `cv/main_<company>_<role><source-extension>` / `cover_letters/cover_<company>_<role><source-extension>`; copy any class/package/font files the template needs into the output directory, or reference them by relative path
|
||||
<!-- END ACTIVE-TEMPLATE -->
|
||||
```
|
||||
|
||||
@@ -174,8 +182,8 @@ Present a summary:
|
||||
|
||||
> **Template `<name>` registered and activated.**
|
||||
>
|
||||
> - Files: `templates/<type>/<name>/` (skeleton, manifest<, class files><, fonts>)
|
||||
> - Test compile: passed with `<engine>` (<N> page(s))
|
||||
> - Files: `templates/<type>/<name>/` (skeleton, manifest<, class/package files><, fonts>)
|
||||
> - Test compile: passed with `<compile command>` (<N> page(s))
|
||||
> - `/apply` will now draft <CVs | cover letters> from this template.
|
||||
>
|
||||
> Useful follow-ups:
|
||||
|
||||
+37
-18
@@ -4,11 +4,17 @@ You are orchestrating a two-agent job application workflow. The job posting is p
|
||||
|
||||
Follow these steps **exactly in order**. Do not skip steps.
|
||||
|
||||
**Standing rule — write new facts back to the profile.** If the user confirms, corrects or supplies a fact that is not already in `01-candidate-profile.md` — a metric, a project detail, a skill, a scope correction — update that file in the same turn. Do not leave it living only in the conversation or in a draft.
|
||||
|
||||
This is not bookkeeping. A fact that exists only in chat **will be treated as unsupported by a later session and stripped from drafts as a fabrication.** Anything absent from the sources does not exist as far as future drafting is concerned, and the loss is silent — a real achievement quietly disappears from every subsequent CV.
|
||||
|
||||
This rule is the input side of the Step 3 Factual Grounding Audit, not a competitor to it. The audit is deliberately strict: an ungrounded claim is removed, and it cannot tell a fabrication from a real fact the user stated out loud last week. That strictness is correct, and it is exactly why confirmed facts have to reach the sources in the same turn they surface. Write to `01-candidate-profile.md` specifically — it is one of the audit's three sources, so a fact recorded there is grounded on the next run. Adding a fact to `01` that `CLAUDE.md` and the master CV simply do not mention is an absence, not a contradiction, and does not trip the audit's profile-consistency warning; if the new fact *corrects* something either of those states, fix it there too rather than leaving the two sources disagreeing.
|
||||
|
||||
**Token-efficiency rules for this workflow:**
|
||||
- Never re-Read a file whose contents are already in your context from an earlier step. If you read it in Step 1, it is still available in Step 2.
|
||||
- When dispatching the reviewer agent, pass draft content **inline in the agent prompt** rather than asking the agent to Read files you already have in memory.
|
||||
- Run the full verification checklist exactly once, at the end (Step 6). The reviewer focuses on content critique, not verification.
|
||||
- Step 5 (compile and inspect PDFs) is mandatory and non-skippable — LaTeX page-break decisions are unpredictable, and `.tex` files that look fine often produce broken PDFs (orphaned entry titles, cover letters spilling to page 2, bullet fonts mismatching).
|
||||
- Step 5 (compile and inspect PDFs) is mandatory and non-skippable — page-break decisions are unpredictable, and source files that look fine often produce broken PDFs (orphaned entry titles, cover letters spilling to page 2, bullet fonts mismatching).
|
||||
|
||||
---
|
||||
|
||||
@@ -60,9 +66,11 @@ Read only the reference files you do not yet have:
|
||||
- `.claude/skills/job-application-assistant/05-cv-templates.md`
|
||||
- `.claude/skills/job-application-assistant/06-cover-letter-templates.md`
|
||||
|
||||
**Resolve the active template (do this once, reuse everywhere below):** if `05-cv-templates.md` or `06-cover-letter-templates.md` opens with an `ACTIVE-TEMPLATE` managed block (inserted by `/add-template`), read its declared **source extension** and **compile command** — these override the stock `.tex`/lualatex (CV) and `.tex`/xelatex (cover letter) defaults for the rest of this workflow. Call these `<CV_EXT>`/`<CV_COMPILE>` and `<COVER_EXT>`/`<COVER_COMPILE>`; where no block is present, they default to `.tex`, the stock lualatex command, and the stock xelatex command respectively. Every `.tex` reference below is really `<CV_EXT>` or `<COVER_EXT>` — stock behavior is unchanged, this only matters when a custom template is active.
|
||||
|
||||
Also read the most recent existing CV and cover letter files for concrete structural reference (one of each is enough):
|
||||
- Read any existing `cv/main_*.tex` file as a LaTeX template reference
|
||||
- Read any existing `cover_letters/cover_*.tex` or `cover_letters/Cover_*.tex` file as a template reference
|
||||
- Read any existing `cv/main_*<CV_EXT>` file as a structural reference
|
||||
- Read any existing `cover_letters/cover_*<COVER_EXT>` or `cover_letters/Cover_*<COVER_EXT>` file as a structural reference
|
||||
|
||||
*The master candidate profile (`01-candidate-profile.md`), the master CV (`cv/main_example.tex`), and CLAUDE.md's Candidate Profile section are the sole source of truth for facts; existing tailored CVs may be read for structure and phrasing only, never as a source of claims.*
|
||||
|
||||
@@ -71,7 +79,7 @@ Also read the most recent existing CV and cover letter files for concrete struct
|
||||
- **Engage nice-to-haves by name** where the profile supports honest adjacency (e.g. "conceptually aligned with <named tool>"), and use the posting's own term over a synonym wherever it is truthfully applicable - including in CV section headings (a posting hiring for "MLOps" should find a heading containing "MLOps", not only a paraphrase).
|
||||
- **Address stated logistics and prerequisites** in the cover letter where the posting raises them: security clearance willingness, start date or availability, commute or location fit, and the posting's reference/job ID where one exists. When the employer operates across several countries, a truthful language-capabilities sentence mapped to their footprint is high-value targeting.
|
||||
|
||||
### CV (`cv/main_<company>_<role>.tex`)
|
||||
### CV (`cv/main_<company>_<role><CV_EXT>`)
|
||||
- In the **CV language from the profile** (the `CV language:` line in CLAUDE.md's Identity section). When the profile does not set one, default to **English**. Never switch language per posting - the CV language is a profile-level choice, so all CVs stay consistent and reusable
|
||||
- Follow the moderncv/banking format from `05-cv-templates.md`
|
||||
- Tailor the profile statement and experience bullets to the specific role
|
||||
@@ -79,7 +87,7 @@ Also read the most recent existing CV and cover letter files for concrete struct
|
||||
- Keep to 2 pages
|
||||
- **Grounding Audit:** Before writing to disk, audit all tailored bullet points against the union of three sources: `.claude/skills/job-application-assistant/01-candidate-profile.md` + the master CV (`cv/main_example.tex`) + `CLAUDE.md`'s Candidate Profile section to verify that all dates, roles, and metrics match exactly (zero profile drift or fabrication).
|
||||
|
||||
### Cover Letter (`cover_letters/cover_<company>_<role>.tex`)
|
||||
### Cover Letter (`cover_letters/cover_<company>_<role><COVER_EXT>`)
|
||||
- **Match the language of the job posting** (Danish posting -> Danish cover letter, English posting -> English cover letter)
|
||||
- Follow the structure from `06-cover-letter-templates.md`
|
||||
- Use the `cover.cls` template
|
||||
@@ -94,7 +102,7 @@ Write both files to disk. Keep the exact text of both drafts in working memory
|
||||
|
||||
## Step 3: REVIEWER - Research & Critique
|
||||
|
||||
Use the **Agent tool** to spawn a `general-purpose` reviewer agent. The reviewer gets a fresh context, so pass the drafts **inline in the prompt** below (do not make the reviewer Read them). Scope the reviewer's file reads to content-critique essentials only — the reviewer does not need the LaTeX template files (`05`, `06`) to critique content, since those govern structural/LaTeX concerns the drafter already applied.
|
||||
Use the **Agent tool** to spawn a `general-purpose` reviewer agent. The reviewer gets a fresh context, so pass the drafts **inline in the prompt** below (do not make the reviewer Read them). Scope the reviewer's file reads to content-critique essentials only — the reviewer does not need the template structure files (`05`, `06`) to critique content, since those govern structural/toolchain concerns the drafter already applied.
|
||||
|
||||
Replace `<COMPANY>`, `<ROLE>`, `<INSERT_JOB_POSTING_TEXT_HERE>`, `<INSERT_CV_DRAFT_HERE>`, and `<INSERT_COVER_LETTER_DRAFT_HERE>` with actual values before dispatching.
|
||||
|
||||
@@ -122,7 +130,7 @@ Read these reference files — and only these — to ground your critique:
|
||||
- The master CV baseline template (`cv/main_example.tex`)
|
||||
- The workspace root `CLAUDE.md` file (specifically the Candidate Profile section)
|
||||
|
||||
Do NOT read `05-cv-templates.md` or `06-cover-letter-templates.md` — those govern LaTeX structure the drafter already applied and are not needed for content critique.
|
||||
Do NOT read `05-cv-templates.md` or `06-cover-letter-templates.md` — those govern template structure the drafter already applied and are not needed for content critique.
|
||||
|
||||
### 3. Factual Grounding Audit
|
||||
Compare every date, employer, job title, and quantitative metric in both drafts against the union of three sources: `.claude/skills/job-application-assistant/01-candidate-profile.md` + the master CV baseline template (`cv/main_example.tex`) + `CLAUDE.md`'s Candidate Profile section. A claim is grounded if ANY of these sources supports it. Mismatches between these three sources themselves must be reported to the user as a profile-consistency warning rather than treated as draft drift. Draft mismatches must be flagged as Part A edits with `"reason": "grounding"` so they can be distinguished from style changes. Keep the tolerance honest: reframed emphasis is fine; changed facts and escalated numbers are not.
|
||||
@@ -130,11 +138,11 @@ Compare every date, employer, job title, and quantitative metric in both drafts
|
||||
### 4. Drafts to Review
|
||||
Both drafts are provided inline below. Do NOT use the Read tool on the draft files — use these exact texts.
|
||||
|
||||
<CV_DRAFT file="cv/main_<COMPANY>_<ROLE>.tex">
|
||||
<CV_DRAFT file="cv/main_<COMPANY>_<ROLE><CV_EXT>">
|
||||
<INSERT_CV_DRAFT_HERE>
|
||||
</CV_DRAFT>
|
||||
|
||||
<COVER_LETTER_DRAFT file="cover_letters/cover_<COMPANY>_<ROLE>.tex">
|
||||
<COVER_LETTER_DRAFT file="cover_letters/cover_<COMPANY>_<ROLE><COVER_EXT>">
|
||||
<INSERT_COVER_LETTER_DRAFT_HERE>
|
||||
</COVER_LETTER_DRAFT>
|
||||
|
||||
@@ -151,7 +159,7 @@ Return your feedback in **two parts**:
|
||||
A JSON array of concrete edits the drafter can apply directly without re-reading the files. Each edit is an object:
|
||||
```json
|
||||
{
|
||||
"file": "cv/main_<COMPANY>_<ROLE>.tex" | "cover_letters/cover_<COMPANY>_<ROLE>.tex",
|
||||
"file": "cv/main_<COMPANY>_<ROLE><CV_EXT>" | "cover_letters/cover_<COMPANY>_<ROLE><COVER_EXT>",
|
||||
"old_string": "<exact text currently in the draft>",
|
||||
"new_string": "<replacement text>",
|
||||
"reason": "<one-line rationale: keyword match / company angle / reframing / style / grounding>"
|
||||
@@ -194,17 +202,20 @@ After all edits are applied, the two files on disk are the final drafts.
|
||||
|
||||
## Step 5: DRAFTER - Compile & Inspect PDFs (MANDATORY)
|
||||
|
||||
**Never skip this step.** The `.tex` files looking fine is not sufficient — LaTeX page-break decisions are unpredictable and commonly produce broken layouts (orphaned job titles separated from their bullets, cover letters spilling to 2 pages, bullet fonts not matching body text). Compile both documents and visually verify the PDFs before presenting.
|
||||
**Never skip this step.** The source files looking fine is not sufficient — page-break decisions are unpredictable and commonly produce broken layouts (orphaned job titles separated from their bullets, cover letters spilling to 2 pages, bullet fonts not matching body text). Compile both documents and visually verify the PDFs before presenting.
|
||||
|
||||
### 5a. Compile
|
||||
|
||||
Use `<CV_COMPILE>` and `<COVER_COMPILE>` resolved in Step 2 (the active template's declared compile command, or the stock defaults below if no custom template is active):
|
||||
|
||||
```bash
|
||||
cd cv && lualatex -interaction=nonstopmode main_<company>_<role>.tex
|
||||
cd ../cover_letters && xelatex -interaction=nonstopmode cover_<company>_<role>.tex
|
||||
```
|
||||
|
||||
- CV uses **lualatex** — pdflatex fails on modern MiKTeX with fontawesome5 font-expansion errors. lualatex handles the same sources cleanly.
|
||||
- Cover letter uses **xelatex** — cover.cls requires fontspec.
|
||||
- **Stock CV** uses **lualatex** — pdflatex fails on modern MiKTeX with fontawesome5 font-expansion errors. lualatex handles the same sources cleanly.
|
||||
- **Stock cover letter** uses **xelatex** — cover.cls requires fontspec.
|
||||
- **Custom template active:** run its declared `<CV_COMPILE>`/`<COVER_COMPILE>` command instead, substituting the actual filename for `<file>`. Never fall back to lualatex/xelatex when a custom template's compile command is a different toolchain (e.g. `typst compile`) — that command is what the manifest actually verified in `/add-template` Step 4.
|
||||
|
||||
If either compile fails, fix the error and re-compile until clean.
|
||||
|
||||
@@ -225,7 +236,7 @@ Read both PDFs via the Read tool and verify:
|
||||
|
||||
### 5c. Iterate until clean
|
||||
|
||||
If the layout has problems, edit the `.tex` files and recompile. Common fixes (see `05-cv-templates.md` and `06-cover-letter-templates.md` for full details):
|
||||
If the layout has problems, edit the source files (`<CV_EXT>`/`<COVER_EXT>`) and recompile. Common fixes below are **LaTeX-specific** (stock templates, or a custom LaTeX template) — see `05-cv-templates.md` and `06-cover-letter-templates.md` for full details, and consult the active template's own manifest ("Known pitfalls") for a non-LaTeX toolchain:
|
||||
|
||||
- **Orphaned CV entry title:** `\usepackage{needspace}` in preamble, then `\needspace{5\baselineskip}` immediately before the problematic `\cventry`
|
||||
- **CV spills to page 3 with only a trailing section:** `\enlargethispage{2-3\baselineskip}` before a late section
|
||||
@@ -256,7 +267,7 @@ Read the `.txt` file.
|
||||
- [ ] **Reading order matches the visual order** — section headings appear in the same sequence as on the page, and lines from different sections are not interleaved. The stock banking template is single-column and safe; custom templates registered via `/add-template` with sidebars or multi-column layouts are where this breaks.
|
||||
- [ ] **Dates recognizable** — each role and degree has its years present in the extraction.
|
||||
|
||||
Failures here are template-level problems: fix them in the `.tex` (e.g. print the email as text rather than icon-only), then re-run 5a–5c and re-extract. If a custom template's layout fundamentally scrambles extraction order, tell the user prominently — they may be trading ATS compatibility for looks.
|
||||
Failures here are template-level problems: fix them in the `<CV_EXT>` source (e.g. print the email as text rather than icon-only), then re-run 5a–5c and re-extract. If a custom template's layout fundamentally scrambles extraction order, tell the user prominently — they may be trading ATS compatibility for looks.
|
||||
|
||||
**3. Keyword coverage.** Reuse the required/preferred keyword list you extracted in Step 1 — do not re-derive it. Match each keyword against the extracted text, **in the posting's language** (when the posting's language differs from the CV language — e.g. a Danish posting against an English CV — a concept the CV legitimately covers in its own language counts as synonym-only; note the language difference). Report a table:
|
||||
|
||||
@@ -273,7 +284,7 @@ Failures here are template-level problems: fix them in the `.tex` (e.g. print th
|
||||
|
||||
### 5e. Clean up build artifacts
|
||||
|
||||
After the final clean compile, delete the `.aux`, `.log`, `.out` files (keep the `.tex` and `.pdf`).
|
||||
After the final clean compile, delete intermediate build files the compile command left behind — LaTeX toolchains leave `.aux`/`.log`/`.out`; a custom template's toolchain may leave nothing beyond the PDF. Keep the source file and the `.pdf`.
|
||||
|
||||
---
|
||||
|
||||
@@ -293,11 +304,19 @@ Summarize 3-5 key decisions made to tailor the application:
|
||||
|
||||
### Files Created
|
||||
List the files written:
|
||||
- `cv/main_<company>_<role>.tex`
|
||||
- `cover_letters/cover_<company>_<role>.tex`
|
||||
- `cv/main_<company>_<role><CV_EXT>`
|
||||
- `cover_letters/cover_<company>_<role><COVER_EXT>`
|
||||
|
||||
Tell the user: "Both files are ready for your review. Open them to check the final output before compiling."
|
||||
|
||||
### Application-Form Fields (Optional Third Artifact)
|
||||
|
||||
Check whether the posting or the portal it came from asks for free-text fields the CV and cover letter don't cover — a self-introduction paragraph, structured project entries, a character-limited pitch, or a motivation/competency question under a word cap (see `.claude/skills/job-application-assistant/08-application-forms.md`, "When this applies"). If it does, or the user has already mentioned the portal, offer it in the same turn:
|
||||
|
||||
> "This posting has free-text application fields I can draft too — [name the specific fields, e.g. a self-introduction paragraph and structured project entries]. Want those drafted?"
|
||||
|
||||
**Only on yes**, read `08-application-forms.md` and draft the fields per its rules, grounded against the same three-source union as the CV and cover letter. Save per that file's "Output format" section. **On no, or when the posting has no such fields, say nothing further and move on** — this is an optional addition and never changes the default two-document output.
|
||||
|
||||
### Next Steps
|
||||
- **Submitted?** `/outcome <company>` logs it in the tracker and starts the per-application record that `/setup` later uses to calibrate the fit framework.
|
||||
- **Interview scheduled?** `/interview` builds a stage-specific prep pack from this posting and the documents you just created.
|
||||
|
||||
@@ -104,4 +104,6 @@ If Step 3 drafted new STAR answers the user approved for keeps, remind them thos
|
||||
2. **Honesty on gaps.** Weak matches get bridge answers (acknowledge → adjacent experience → learning path), never invented experience. Same rule as everywhere else in this repo.
|
||||
3. **Verified research only.** Company specifics go in the pack only after independent confirmation. Interviewer notes stick to public professional information.
|
||||
4. **Stage-appropriate prep.** A phone screen pack and a final-round pack are different documents; recorded feedback from earlier stages takes priority over generic question lists.
|
||||
5. **Write only to the application archive.** The prep pack lands in `documents/applications/<company>_<role>/`; framework and profile files are never edited, except appending user-approved STAR examples to `07-interview-prep.md` on explicit request.
|
||||
5. **Write only to the application archive** — with one exception. The prep pack lands in `documents/applications/<company>_<role>/`; framework files are not edited, except appending user-approved STAR examples to `07-interview-prep.md` on explicit request.
|
||||
|
||||
**The exception is `01-candidate-profile.md`.** Interview prep is where new facts surface most often: the user recalls a metric, corrects a scope, or fills in a STAR stub. When that happens, write the fact into the profile, as well as putting it in the prep pack. A fact recorded only in prep material reads as unsupported to a later drafting session and gets stripped from CVs as a fabrication. Prep files are not a substitute for the profile.
|
||||
|
||||
@@ -93,24 +93,25 @@ Ranked <N> new postings (<X> shortlisted, <Y> below threshold, <Z> expired/vetoe
|
||||
|
||||
### Shortlist
|
||||
|
||||
| # | Score | Verdict | Title | Company | Location | Deadline | |
|
||||
|---|-------|---------|-------|---------|----------|----------|---|
|
||||
| 1 | 78 | Strong Fit | ... | ... | ... | ... | 🔥 |
|
||||
| # | Score | Verdict | Title | Company | Location | Deadline | | URL |
|
||||
|---|-------|---------|-------|---------|----------|----------|---|-----|
|
||||
| 1 | 78 | Strong Fit | ... | ... | ... | ... | 🔥 | [Link](...) |
|
||||
|
||||
### Why these ranked highest
|
||||
**1. <Title> at <Company> (78)** - [2-3 strength bullets and the honest gap, from the agent's findings]
|
||||
[repeat for each shortlisted job]
|
||||
|
||||
### Below threshold
|
||||
| Score | Verdict | Title | Company | One-line reason |
|
||||
| Score | Verdict | Title | Company | One-line reason | URL |
|
||||
|
||||
### Excluded
|
||||
- <Title> at <Company> - location FAIL: requires relocation
|
||||
- <Title> at <Company> - expired <date>
|
||||
- <Title> at <Company> - location FAIL: requires relocation - [Link](...)
|
||||
- <Title> at <Company> - expired <date> - [Link](...)
|
||||
```
|
||||
|
||||
Rules for the presentation:
|
||||
|
||||
- Every table (shortlist, below threshold, excluded) includes the posting URL as a clickable link - link to the entry's `url` field in `seen_jobs.json` (not the entry's key, which for some portals is a company+title composite rather than the URL), so this never requires an extra lookup. Never drop the link for brevity.
|
||||
- Every claim traces to fetched posting text or the profile - no invented details.
|
||||
- Say explicitly that these are **triage scores from the posting text only**, and that `/apply` will re-evaluate with company research before anything is drafted.
|
||||
- Then ask: "Want to apply to any of these? Give me the number(s) and I'll start with the full `/apply` workflow."
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
framework_version: 1.2.1
|
||||
framework_version: 1.3.0
|
||||
---
|
||||
|
||||
# CV Templates and Tailoring Guide
|
||||
@@ -136,11 +136,45 @@ Use the posting's own core term in the matching bullet's bold label when it trut
|
||||
- For senior roles, keep education brief (dates and titles only)
|
||||
- Include thesis topics when relevant to the target role
|
||||
|
||||
#### In-progress qualifications must say so explicitly
|
||||
|
||||
**A bare year range is not enough.** An entry reading `2025–2026`, seen partway through 2026, looks like a *finished* degree, because a reader skimming a CV treats a closed range as closed. A profile statement that says "currently completing…" does not fix it: the education entry is where a reader checks the credential, so it has to stand on its own.
|
||||
|
||||
State completion inside the entry itself:
|
||||
|
||||
```latex
|
||||
\item{\cventry{2025--2026}{[Degree], [Field]}{[Institution]}{[Location]}{}{\vspace{1pt}
|
||||
In progress, expected [Month Year]. [Relevant topics]
|
||||
}}
|
||||
```
|
||||
|
||||
Any consistent form works: `In progress, expected <Month Year>.` / `Expected completion <Month Year>.` / a date field of `2025–present`.
|
||||
|
||||
Claiming a credential not yet held is a factual misstatement, and it is the kind discovered at transcript or reference check rather than at interview. It costs nothing to prevent. The same applies to in-progress certifications and courses.
|
||||
|
||||
**Check for agreement:** for a current student, the profile statement, the education entry, and any availability or work-permit note must all give the same completion date. Contradiction between them is worse than any single version.
|
||||
|
||||
### Professional Experience
|
||||
- Rewrite bullet points to emphasize aspects most relevant to the target role
|
||||
- Use 4-6 bullets for most recent role, 3-4 for previous, 2-3 for older
|
||||
- **Emphasize measurable results** where possible: "Reduced processing time by X%", "Model adopted by the team"
|
||||
|
||||
#### Check tenure against visible output
|
||||
|
||||
Before finalizing, look at each role the way a stranger will: **date span versus how much work is shown.** A two-year role represented by a single project reads as low output, whether or not that is fair. The reader cannot know what filled the time, so they guess, and the guess is unflattering.
|
||||
|
||||
This bites hardest on **career changers** (part of the tenure went into learning the new field), on **long-cycle work** (industrial deployment, clinical or regulatory projects, research — one delivery genuinely takes quarters), and on anyone whose employer kept them on a single account or product.
|
||||
|
||||
Three honest fixes, in order of preference:
|
||||
|
||||
1. **Surface more real work.** Ask what else the period contained. There are often real secondary projects, internal tooling, or support work that never reached the CV because it felt minor. Best fix when the material exists.
|
||||
2. **Make the phases within the role explicit.** If the span genuinely had stages, say so — an initial period learning the domain or supporting the team, then ownership of the named work through to delivery. A phased arc reads as a growth curve; an undifferentiated multi-year block reads as stagnation.
|
||||
3. **Name what made the cycle long.** Data collection from a live environment, validation with domain experts, deployment and iteration against real output. Reviewers who know the domain accept this immediately.
|
||||
|
||||
**Never** pad with invented projects, and **never** quietly shorten the employment dates so the ratio looks better. Both are discoverable, and both are worse than the perception problem being solved.
|
||||
|
||||
**Prepare the interview answer too.** If a long span against little visible output survives these fixes, the question is coming. The candidate needs a ready two-part answer — what actually filled the time, and what the outcome was — recorded in their interview prep rather than improvised in the room.
|
||||
|
||||
### Handling Employment Gaps (Best Practice)
|
||||
If there is a gap in your employment history:
|
||||
- The gap should be explained matter-of-factly if needed
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
framework_version: 1.0.0
|
||||
---
|
||||
|
||||
# Application Form Fields
|
||||
|
||||
`/apply` produces two artifacts: a CV and a cover letter. Many applications need a **third** — free-text fields typed directly into an application portal. Graduate programs, large-employer ATS systems and startup forms routinely ask for things neither document covers, under a character or word limit, in a box with no formatting.
|
||||
|
||||
This file governs that third artifact. It is not a document you compile; it is text the candidate pastes.
|
||||
|
||||
## When this applies
|
||||
|
||||
Trigger it whenever a posting or portal asks for any of:
|
||||
|
||||
- A self-introduction / personal statement / "tell us about yourself" paragraph
|
||||
- Structured project entries (project name, role, start and end date, description)
|
||||
- A short pitch under a hard character limit ("stand out in 140 characters", "why you, in one sentence")
|
||||
- Motivation questions ("why this company", "why this program")
|
||||
- Competency questions with a word cap ("describe a time you…", 200 words)
|
||||
|
||||
## The rule that governs everything here
|
||||
|
||||
**Every claim in a form field must already be defensible from the same sources the CV and cover letter are grounded against** — the union of `01-candidate-profile.md`, the master CV (`cv/main_example.tex`), and `CLAUDE.md`'s Candidate Profile section, with a claim grounded if ANY of the three supports it. The interviewer reads the form alongside the CV. A form field is not a place to introduce new claims, inflate scope, or fill space — it is a place to *select* from what is already true and arrange it for the question asked.
|
||||
|
||||
All accuracy rules from `05-cv-templates.md` and `03-writing-style.md` apply unchanged.
|
||||
|
||||
## Field type: self-introduction paragraph
|
||||
|
||||
Usually 100–200 words, one paragraph, no formatting.
|
||||
|
||||
**Structure that works:**
|
||||
1. Current status — what they are doing or completing now
|
||||
2. The single strongest piece of evidence, with its number and scale
|
||||
3. One line of trajectory: how they got here, if a pivot or specialisation is genuinely interesting
|
||||
4. What they want next, connected to this employer's actual work
|
||||
|
||||
**Rules:**
|
||||
- **Lead with the strongest evidence, not chronology.** A career history told in order buries the best material when the strongest work is recent.
|
||||
- **Write one version per role type, not one for all applications.** The same history framed for a backend role and a data role are different paragraphs. Produce both, label them, and say which goes where.
|
||||
- **Tie it to this employer in the final sentence.** Generic self-introductions are the default and read as such.
|
||||
- **Count the words and state the count.** Portals truncate silently. Supply a trimmed variant and name which sentence to cut first.
|
||||
|
||||
## Field type: structured project entries
|
||||
|
||||
Typically **project name, role, start date, end date, description.**
|
||||
|
||||
**Project name.** Give the project a descriptive name, not the employer's name — "Warehouse Inventory Forecasting Platform" is a project, "Acme Corp" is an employer. Where a client is more recognisable than the employer, name the client only if the relationship is truthful (placed on-site with, delivered to).
|
||||
|
||||
**Role.** The candidate's role *on that project*, which may be narrower than their job title. Do not upgrade it.
|
||||
|
||||
**Dates.** The dates they worked on **that project**, which are not automatically the employment dates. If a role spanned two years but the named project occupied the later part, saying so is both more accurate and avoids the low-output reading described in `05-cv-templates.md` ("Check tenure against visible output"). Only narrow the dates when the candidate can say when the project actually started — never invent a boundary to improve the ratio.
|
||||
|
||||
**Description.** 100–150 words: what the system did and who used it, then the hardest technical problem and how it was solved, then the outcome with its number. Supply a **~60-word short version** as well; portals vary and the candidate should not have to improvise a cut.
|
||||
|
||||
**Scope discipline is stricter here than on a CV.** A CV bullet can be terse enough to be ambiguous about ownership. A project entry with the candidate's name and role attached reads as ownership of the whole thing. Where they contributed rather than owned, say so inside the description.
|
||||
|
||||
## Field type: hard character limits
|
||||
|
||||
These reward **a specific situation over an adjective**. Most applicants submit adjectives — "passionate", "fast learner", "team player" — so a concrete situation stands out by contrast.
|
||||
|
||||
**Method:**
|
||||
1. Pick the single most distinctive true thing: usually a number, an unusual combination of backgrounds, or a problem shape that maps onto the employer's own work.
|
||||
2. Draft 4–6 candidates at different angles.
|
||||
3. **Count characters programmatically. Do not estimate.** Over-limit text is truncated mid-word.
|
||||
4. Present all candidates with counts, recommend one, and say why.
|
||||
|
||||
Prefer the version that **maps the candidate's problem onto the employer's problem**, where a truthful mapping exists. That is what "stand out" is actually asking for.
|
||||
|
||||
## Output format
|
||||
|
||||
Save to a plain `.txt` file the candidate can copy from, alongside their other application material for that employer. One file per employer, containing every field that employer asked for.
|
||||
|
||||
Include:
|
||||
- A header naming the employer and the roles it covers
|
||||
- Each field, labelled, with word or character counts stated
|
||||
- Short variants where limits may be tighter than expected
|
||||
- **`NOTE TO SELF` blocks** for scope reminders and prepared answers to questions the content invites — clearly marked as *not for pasting into the form*
|
||||
- A dates quick-reference, so date fields stay consistent without re-deriving them
|
||||
|
||||
## Verification before handing it over
|
||||
|
||||
- [ ] Every factual claim traces to the union of `01-candidate-profile.md`, the master CV (`cv/main_example.tex`), and `CLAUDE.md`'s Candidate Profile section
|
||||
- [ ] No claim contradicts the CV or cover letter submitted for the same role
|
||||
- [ ] Ownership scoped correctly on contributory work
|
||||
- [ ] Word and character counts measured, not estimated
|
||||
- [ ] In-progress qualifications described as in progress
|
||||
- [ ] `NOTE TO SELF` blocks clearly marked as internal
|
||||
@@ -5,7 +5,7 @@ description: >
|
||||
and preparing for interviews. Triggers on keywords like: job posting, job application, CV,
|
||||
cover letter, resume, interview prep, job fit, career, application, apply, ansøgning, stilling
|
||||
allowed-tools: Read, Glob, Grep, WebFetch, WebSearch, Edit, Write, AskUserQuestion
|
||||
framework_version: 1.0.1
|
||||
framework_version: 1.1.0
|
||||
---
|
||||
|
||||
# Job Application Assistant
|
||||
@@ -56,6 +56,7 @@ When the user provides a job posting (URL or text), follow this workflow:
|
||||
| `05-cv-templates.md` | LaTeX CV structure and tailoring rules |
|
||||
| `06-cover-letter-templates.md` | LaTeX cover letter structure and tailoring rules |
|
||||
| `07-interview-prep.md` | STAR examples, tough questions, roleplay guidelines |
|
||||
| `08-application-forms.md` | Portal free-text fields: self-introduction, project entries, character-limited pitches |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -99,6 +99,12 @@ For every candidate:
|
||||
- Skip if the URL or company+title combo already exists in `seen_jobs.json`
|
||||
- Skip if the company+role already appears in `job_search_tracker.csv`
|
||||
|
||||
### Step 2.5: Mass-Posting Detection (within this run)
|
||||
|
||||
A distribution pattern worth flagging to the user as a caution signal, not as an accusation against the employer - it describes how a listing is being distributed, not a verdict on whether the company is legitimate. It alone proves nothing is wrong (companies do legitimately hire the same role across several cities); flag it so the user can factor it in when deciding whether to invest time, don't downgrade fit or silently exclude the result because of it.
|
||||
|
||||
If two or more results in this run's pool (from the same company, or sharing the same req/job ID visible in the URL or title) have substantially the same description and differ only in city/location/title, don't present them as separate rows. Consolidate into a single row and note the spread, e.g. "posted identically across 6 cities (BR, MX, GT)".
|
||||
|
||||
### Step 3: Quick Fit Assessment
|
||||
|
||||
For each new job, do a rapid fit check (NOT the full evaluation from `04-job-evaluation.md` - just a quick signal):
|
||||
@@ -197,11 +203,13 @@ health: <portal-name> - broken (0 results for the SKILL.md test query and a broa
|
||||
|---|-----|-------|---------|----------|----------|-----|
|
||||
| 1 | High | ... | ... | ... | ... | [Link](...) |
|
||||
|
||||
If Step 2.5 flagged a mass-posting pattern, note it in the Title cell (e.g. "Frontend Developer (posted in 6 cities)") rather than burying it - it's a signal the user should see at a glance, not just in the detail highlights below.
|
||||
|
||||
### High-Match Highlights
|
||||
For each high-match job, add 2-3 bullet points:
|
||||
- Why it matches your profile
|
||||
- Key requirements to check
|
||||
- Any red flags
|
||||
- Any red flags (including mass-posting signals from Step 2.5)
|
||||
|
||||
### Contacts
|
||||
For each high/medium-fit job from Step 4.5, add a short contacts block with the two
|
||||
@@ -233,3 +241,4 @@ If the user decides to apply to any job, add a row to `job_search_tracker.csv`.
|
||||
6. **Parallel searches.** Run portal CLI searches in parallel; use WebSearch only for gaps the CLIs don't cover.
|
||||
7. **No automated people lookups.** Referral contacts (Step 4.5) are LinkedIn search links only - never fetch or scrape LinkedIn people-search result pages programmatically.
|
||||
8. **Health checks are bounded and honest.** Step 4.75 spends at most one probe, one retry, and (in `health` mode) one detail fetch per portal - a diagnosis, not a crawl. A rate-limit is never evidence of breakage. Health verdicts come only from observed CLI output; a portal that could not be tested is reported as inconclusive, never guessed. The `enabled` toggle is the only thing the health check may edit, and only with confirmation.
|
||||
9. **Flag distribution patterns, never accuse.** The mass-posting signal (Step 2.5) describes how a listing is being distributed, not a claim that the employer is a scam. Never name a company as fraudulent or untrustworthy - present the observation and let the user decide.
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
github: MadsLorentzen
|
||||
ko_fi: madslorentzen
|
||||
|
||||
@@ -72,14 +72,14 @@ jobs:
|
||||
- run: python -m unittest discover -s tests -t . -v
|
||||
|
||||
dependency-review:
|
||||
name: Dependency review (upstream PRs only)
|
||||
# Requires the repo's Dependency graph, which forks never inherit and
|
||||
# which may be disabled upstream - so: upstream PRs only, and the
|
||||
# graph is probed first. If it is unavailable, the job warns and
|
||||
# passes instead of hard-failing (the same graceful-skip pattern the
|
||||
# workflow uses for optional tools). Enabling Dependency graph under
|
||||
# Settings -> Advanced Security activates the real check.
|
||||
if: github.event_name == 'pull_request' && github.repository == 'MadsLorentzen/ai-job-search'
|
||||
name: Dependency review
|
||||
# Requires the repo's Dependency graph, which not every repo (upstream or
|
||||
# fork) has enabled - so the graph is probed first, and the job warns and
|
||||
# passes instead of hard-failing if it's unavailable (the same
|
||||
# graceful-skip pattern the workflow uses for optional tools), rather than
|
||||
# being gated to a specific repository. Enabling Dependency graph under
|
||||
# Settings -> Advanced Security activates the real check on any repo.
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
+9
-3
@@ -50,11 +50,17 @@ Thumbs.db
|
||||
skills-lock.json
|
||||
|
||||
# Personal application output files (generated by /apply — do not share)
|
||||
cv/main_*.tex
|
||||
# Extension-agnostic on the ignore side: a custom template registered via
|
||||
# /add-template (e.g. Typst) writes main_<company>_<role>.typ instead of
|
||||
# .tex, and it must be ignored just as reliably as the stock LaTeX output.
|
||||
# The negations stay .tex-only - the stock example files are always LaTeX,
|
||||
# and a wildcard negation (!cv/main_example.*) would also re-include build
|
||||
# artifacts like main_example.pdf/.aux.
|
||||
cv/main_*.*
|
||||
!cv/main_example.tex
|
||||
cv/*.txt
|
||||
cover_letters/cover_*.tex
|
||||
cover_letters/Cover_*.tex
|
||||
cover_letters/cover_*.*
|
||||
cover_letters/Cover_*.*
|
||||
!cover_letters/cover_example.tex
|
||||
|
||||
# documents/ subfolder contents are personal — only README and folder structure are tracked
|
||||
|
||||
+62
-1
@@ -13,7 +13,68 @@ per-file diff commands.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
_Changes landed on `master` since the last release will be listed here._
|
||||
## [1.1.0] - 2026-07-30
|
||||
|
||||
### Security & privacy
|
||||
|
||||
- **Personalized custom-template files are now gitignored regardless of engine** - the
|
||||
ignore rules broadened from `cv/main_*.tex` to `cv/main_*.*` (and likewise for cover
|
||||
letters), so a fork using a Typst or other non-LaTeX template no longer commits
|
||||
personalized `main_<company>.typ` files to a public fork. The `*_example.tex` files stay
|
||||
tracked. If you registered a custom template before this release, check
|
||||
`git status` once after updating. (#238)
|
||||
- **Dependency review is live, for forks too** - the repo's Dependency graph is now enabled,
|
||||
so the CI `dependency-review` job actually blocks PRs that introduce dependencies with
|
||||
known high-severity vulnerabilities, and the job is no longer gated to the upstream repo:
|
||||
forks get the same check, self-activating if the fork enables Dependency graph
|
||||
(it warns-and-passes otherwise). (#254)
|
||||
|
||||
### Added
|
||||
|
||||
- **freehire-search: full descriptions come back with the search** - `search` now calls
|
||||
freehire's agent search endpoint (`/api/v1/agent/jobs/search`), which serves each hit's
|
||||
complete description instead of the search index's truncated preview. A 20-role search is
|
||||
one request rather than 1 + 20 `detail` calls, and `/scrape`'s Step 2 no longer needs a
|
||||
per-hit fetch for this portal. `--description-format markdown|text|html` (default
|
||||
`markdown`) selects the rendering; `table` and `plain` output is unchanged. (#251)
|
||||
- **Custom templates: any compile-to-PDF toolchain (Typst, ...)** - `/add-template` no longer
|
||||
hardcodes a `lualatex`/`xelatex`/`pdflatex` engine enum. Custom templates now declare a
|
||||
source extension and a full compile command, so Typst (`typst compile`) registers the same
|
||||
way a custom LaTeX template does. Stock CV/cover letter templates stay LaTeX,
|
||||
unchanged. (#238)
|
||||
- **Application-form fields as an optional third `/apply` artifact** - when a posting's
|
||||
application form asks screening questions, `/apply` can now offer a prep sheet of
|
||||
grounded answers alongside the CV and cover letter. Opt-in; the default two-document
|
||||
output never changes. (#212)
|
||||
- **Confirmed facts write back to the profile** - when `/apply` or `/interview` surfaces a
|
||||
fact the user confirms (a skill, a date, a project detail), it is written back to the
|
||||
profile files in the same turn instead of being lost with the conversation. (#211)
|
||||
- **CV methodology: in-progress qualifications and tenure-vs-output** - `05-cv-templates.md`
|
||||
gains explicit rules for stating in-progress certifications/degrees honestly and for
|
||||
checking claimed tenure against visible output (`framework_version` 1.2.1 -> 1.3.0). (#210)
|
||||
- **Scraper flags mass-posting and recycled-listing patterns** - `/scrape` marks postings
|
||||
that look bulk-posted or recycled so they don't eat evaluation effort. (#207)
|
||||
- **Retry contract pinned in CI** - all six portal CLIs now carry 429/5xx retry-backoff
|
||||
tests covering every fetch wrapper, so a silent regression in retry behavior trips
|
||||
CI. (#246)
|
||||
- **README: the extension model, documented** - new Customization subsection "Extending the
|
||||
framework: portals, templates, criteria - and borrowing from other forks": the three
|
||||
extension points, the copy-one-folder pattern for borrowing a portal skill from another
|
||||
fork with a read-the-code-first checklist, and why there is deliberately no installer
|
||||
(the manual copy is the security model). Prompted by discussion #249.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `/rank` shortlist and below-threshold tables include each posting's URL. (#236)
|
||||
- `convert_salary_excel.py`: count/index columns pair by category name instead of
|
||||
adjacency (#219), standalone count columns store as counts (#230), and ragged rows from
|
||||
dimension-less spreadsheets no longer crash with an IndexError (#252).
|
||||
- `cover.cls`: duplicate package imports removed and the `\ProvidesClass` name fixed to
|
||||
match the filename, silencing a class-name-mismatch warning. (#252)
|
||||
- Portal CLI type-checking pinned to concrete `@types/bun` / `@bunli/*` versions to stop
|
||||
environmental CI type-drift. (#226)
|
||||
- `freehire-search` points at freehire.me after the service's domain migration. (#229)
|
||||
- `verify_pdf.py`'s missing-poppler error now includes per-OS install hints. (#252)
|
||||
|
||||
## [1.0.0] - 2026-07-22
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ After creating or updating a CV or cover letter, re-read the generated file and
|
||||
|
||||
### Compiled PDF verification (MANDATORY - never skip)
|
||||
Both documents MUST be compiled and visually inspected via the Read tool on the PDF output. "Looks fine in the .tex" is not acceptable - LaTeX page-break decisions are unpredictable. Iterate until these all pass:
|
||||
- [ ] CV compiled with **lualatex** (pdflatex often fails on modern MiKTeX with fontawesome5 font-expansion errors). Cover letter compiled with **xelatex** (cover.cls requires fontspec).
|
||||
- [ ] CV compiled with **lualatex** (pdflatex often fails on modern MiKTeX with fontawesome5 font-expansion errors). Cover letter compiled with **xelatex** (cover.cls requires fontspec). If a custom template is active (registered via `/add-template`), compile with its declared command instead — see the `ACTIVE-TEMPLATE` block in `05-cv-templates.md`/`06-cover-letter-templates.md`.
|
||||
- [ ] **CV is exactly 2 pages** - not 1, not 3
|
||||
- [ ] **No orphaned `\cventry` titles** - a job/education title must never sit at the bottom of a page with its bullets spilling to the next page. Use `\needspace{5\baselineskip}` before each `\cventry` to prevent this, and `\enlargethispage{2-3\baselineskip}` to rescue a trailing section that just barely spills
|
||||
- [ ] **Cover letter is exactly 1 page** - signature block must fit with the body, never overflow
|
||||
|
||||
@@ -145,7 +145,7 @@ Postings are treated as untrusted input (the workflow follows no instructions em
|
||||
- **`/expand`** enriches your profile by scanning public sources you've already linked in it (GitHub repos, portfolio site, Kaggle, Google Scholar) and looking up syllabi for named courses and certifications. Discovered competencies are added to your profile with a source tag. Useful right after `/setup` to surface skills that documents alone don't make explicit.
|
||||
- **`/upskill`** analyzes the gap between your profile and your tracked job postings (or a single posting via `/upskill <URL>`). Produces a prioritized heatmap of skill gaps and a learning plan with web-searched study resources and time estimates. Useful for career planning between applications.
|
||||
- **`/html-report`** generates a self-contained HTML dashboard from `job_search_tracker.csv` and the application archives — stat cards, status/sector/channel/funnel charts (inline SVG, no external dependencies), and a filterable applications table. Opens directly in a browser, fully offline. Re-run it any time after `/outcome` adds new entries.
|
||||
- **`/add-template`** registers your own LaTeX CV or cover letter template in place of the stock ones. It captures the template's instructions (compile engine, fonts, style rules, page limit), runs a mandatory test compile, and wires the template into `/apply`. See [LaTeX templates](#latex-templates) below.
|
||||
- **`/add-template`** registers your own CV or cover letter template (LaTeX, Typst, or another toolchain) in place of the stock ones. It captures the template's instructions (source extension, compile command, fonts, style rules, page limit), runs a mandatory test compile, and wires the template into `/apply`. See [Custom templates](#custom-templates) below.
|
||||
- **`/add-portal`** generates a job-portal search skill for a job board in your market. It investigates the portal (search URL pattern, result structure, access rules), scaffolds the CLI skill from the same structure as the shipped ones, and test-runs a live query before registering. See [Job search tools](#job-search-tools) below.
|
||||
|
||||
`/reset` is also available, see [Starting over](#starting-over) below.
|
||||
@@ -160,7 +160,7 @@ ai-job-search/
|
||||
│ │ ├── apply.md # /apply workflow (drafter-reviewer)
|
||||
│ │ ├── setup.md # /setup onboarding (documents folder, CV import, or interview)
|
||||
│ │ ├── expand.md # /expand competency enrichment from documents and online presence
|
||||
│ │ ├── add-template.md # /add-template register custom LaTeX templates
|
||||
│ │ ├── add-template.md # /add-template register custom templates (LaTeX, Typst, ...)
|
||||
│ │ ├── add-portal.md # /add-portal generate a job-portal search skill for your market
|
||||
│ │ ├── rank.md # /rank triage scraped jobs into a ranked shortlist
|
||||
│ │ ├── outcome.md # /outcome record application results, archive materials
|
||||
@@ -188,7 +188,7 @@ ai-job-search/
|
||||
│ ├── jobindex-search/ # Jobindex.dk (Denmark)
|
||||
│ ├── jobnet-search/ # Jobnet.dk (Denmark, government portal)
|
||||
│ ├── linkedin-search/ # LinkedIn public job listings (country-agnostic)
|
||||
│ └── freehire-search/ # freehire.dev tech job aggregator (multi-market, REST API)
|
||||
│ └── freehire-search/ # freehire.me tech job aggregator (multi-market, REST API)
|
||||
├── cv/
|
||||
│ └── main_example.tex # moderncv LaTeX template
|
||||
├── cover_letters/
|
||||
@@ -267,17 +267,17 @@ As your priorities evolve, you can reconfigure just the job search without re-ru
|
||||
|
||||
This re-runs the search configuration interview: which roles to target, which skills to search for, which locations, and which portals. It also suggests role types you may not have considered based on your profile.
|
||||
|
||||
### LaTeX templates
|
||||
### Custom templates
|
||||
|
||||
The CV uses [moderncv](https://ctan.org/pkg/moderncv) (banking style). The cover letter uses a custom `cover.cls` with Lato/Raleway fonts.
|
||||
The CV uses [moderncv](https://ctan.org/pkg/moderncv) (banking style). The cover letter uses a custom `cover.cls` with Lato/Raleway fonts. Both are LaTeX — the reference engine this repo ships and maintains.
|
||||
|
||||
To use your own template instead, run:
|
||||
To use your own template instead — LaTeX, [Typst](https://typst.app/), or any other toolchain that compiles to PDF from the command line — run:
|
||||
|
||||
```
|
||||
/add-template
|
||||
```
|
||||
|
||||
Point it at your `.tex` file (plus any `.cls`/`.sty` files or bundled fonts). The command interviews you for the template's instructions — compile engine, fonts and where they live, style rules to preserve, hard page limit — stores everything under `templates/`, runs a mandatory test compile, and activates the template so `/apply` drafts from it. Templates are stored with `[PLACEHOLDER]` tokens instead of personal data, so they're safe to commit and share.
|
||||
Point it at your source file (a `.tex` file plus any `.cls`/`.sty` files or bundled fonts; a `.typ` file plus any local packages; or an equivalent for another toolchain). The command interviews you for the template's instructions — source extension, compile command, fonts and where they live, style rules to preserve, hard page limit — stores everything under `templates/`, runs a mandatory test compile, and activates the template so `/apply` drafts and compiles from it. Templates are stored with `[PLACEHOLDER]` tokens instead of personal data, so they're safe to commit and share.
|
||||
|
||||
- `/add-template --list` shows registered templates
|
||||
- `/add-template --use <name>` switches between them
|
||||
@@ -300,7 +300,25 @@ Maintaining a fork adapted to your market or language? Add it to the [Community
|
||||
For **country-agnostic** starting points outside Denmark, the repo ships two portal skills alongside the Danish demos:
|
||||
|
||||
- **`linkedin-search`** — built on LinkedIn's public, unauthenticated `jobs-guest` endpoints. Field-agnostic, **zero runtime dependencies** (runs with just `bun`), and takes the search location as an explicit flag, so it works for any market out of the box (`-l "Berlin, Germany"`, `-l "Mumbai, Maharashtra, India"`, `-l "Remote"`, …). Intended for **personal use only** — automated access is against LinkedIn's Terms of Service, so keep volume low. See `.agents/skills/linkedin-search/SKILL.md`.
|
||||
- **`freehire-search`** — queries the [freehire.dev](https://freehire.dev) aggregator's public REST API (JSON, no API key). Tech-focused (software, data, engineering, DevOps, remote), multi-market via facet flags (`--region`, `--country`, `--remote`), and **zero runtime dependencies**. Unlike the HTML-scraping Danish portals, results come back structured (skills, seniority, category). The backend is MIT-licensed and [self-hostable](https://github.com/strelov1/freehire) — point `FREEHIRE_API_URL` at your own instance if you prefer. See `.agents/skills/freehire-search/SKILL.md`.
|
||||
- **`freehire-search`** — queries the [freehire.me](https://freehire.me) aggregator's public REST API (JSON, no API key). Tech-focused (software, data, engineering, DevOps, remote), multi-market via facet flags (`--region`, `--country`, `--remote`), and **zero runtime dependencies**. Unlike the HTML-scraping Danish portals, results come back structured (skills, seniority, category). The backend is MIT-licensed and [self-hostable](https://github.com/strelov1/freehire) — point `FREEHIRE_API_URL` at your own instance if you prefer. See `.agents/skills/freehire-search/SKILL.md`.
|
||||
|
||||
### Extending the framework: portals, templates, criteria - and borrowing from other forks
|
||||
|
||||
Everything above adds up to an extension model, so here it is stated plainly. The framework has three extension points, and none of them require touching upstream:
|
||||
|
||||
1. **Portal skills** - the module system for job boards. Every `*-search` skill is a self-contained folder under `.agents/skills/` with the same contract (a `search`/`detail` CLI, `--format json|table|plain` output, an `enabled:` flag in its `SKILL.md`, its own tests). `/scrape` auto-discovers any installed skill that follows the contract - nothing to register, nothing to wire up. `/add-portal` generates new ones; the [community portal index](https://github.com/MadsLorentzen/ai-job-search/discussions/78) catalogs the ones other forks have built.
|
||||
2. **Document templates** - `/add-template` registers any CV or cover-letter toolchain that compiles to PDF from the command line, LaTeX or otherwise.
|
||||
3. **Evaluation criteria** - deal-breakers and preferences in your profile are free-form, and the evaluation rubric scores against whatever you put there. "Strong parental-leave terms", "minimum salary X per my union's scale", "no on-call" - each is one profile line, no code, and it carries real weight in `/rank` and `/apply` fit evaluations.
|
||||
|
||||
**Borrowing a portal skill from another fork** is the intended way to get a board that upstream doesn't ship: find it in the [portal index](https://github.com/MadsLorentzen/ai-job-search/discussions/78), open that fork, and copy the one folder into your own `.agents/skills/`. Before you run it:
|
||||
|
||||
- **Read the code.** All of it - these CLIs run pre-approved on your machine (`.claude/settings.json` allowlists them) against your career data. Check that the only network calls go to the job board it claims to search, that `package.json` has no `dependencies` and no lifecycle scripts (`postinstall` etc.), and that nothing reads or writes outside its own folder.
|
||||
- **Run its tests offline** (`bun test` in the skill's `cli/` directory) - a well-built skill's tests pass with no network access.
|
||||
- Check the `enabled:` flag and the skill's own ToS notes.
|
||||
|
||||
The copy step is manual on purpose. Your settings already allow installed portal skills to run without asking each time - so an installer that fetched them from third-party repos for you would skip the one check that matters: you, reading the code first. There isn't one, and that's a security decision rather than a missing feature.
|
||||
|
||||
Market-specific *data sources* (a national salary database, local award-rate tables) follow the same pattern as portals: they belong in a market fork, shared via [#78](https://github.com/MadsLorentzen/ai-job-search/discussions/78), not upstream.
|
||||
|
||||
### Salary benchmarking
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
% Intro Options
|
||||
\ProvidesClass{deedy-resume-openfont}[2014/04/30 CV class]
|
||||
\ProvidesClass{cover}[2024/04/30 Cover letter class]
|
||||
\NeedsTeXFormat{LaTeX2e}
|
||||
\DeclareOption{print}{\def\@cv@print{}}
|
||||
\DeclareOption*{%
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
% Intro Options
|
||||
\ProvidesClass{deedy-resume-openfont}[2014/04/30 CV class]
|
||||
\ProvidesClass{cover}[2024/04/30 Cover letter class]
|
||||
\NeedsTeXFormat{LaTeX2e}
|
||||
\DeclareOption{print}{\def\@cv@print{}}
|
||||
\DeclareOption*{%
|
||||
@@ -21,20 +21,16 @@
|
||||
\renewcommand\refname{\vskip -1.5cm}
|
||||
|
||||
% Color definitions
|
||||
\usepackage[usenames,dvipsnames]{xcolor}
|
||||
\definecolor{date}{HTML}{666666}
|
||||
\definecolor{primary}{HTML}{2b2b2b}
|
||||
\definecolor{headings}{HTML}{6A6A6A}
|
||||
\definecolor{subheadings}{HTML}{333333}
|
||||
|
||||
% Set main fonts
|
||||
\usepackage{fontspec}
|
||||
\setmainfont[Color=primary, Path = OpenFonts/fonts/lato/,BoldItalicFont=Lato-RegIta,BoldFont=Lato-Reg,ItalicFont=Lato-LigIta]{Lato-Lig}
|
||||
\setsansfont[Scale=MatchLowercase,Mapping=tex-text, Path = OpenFonts/fonts/raleway/]{Raleway-ExtraLight}
|
||||
|
||||
% Date command
|
||||
\usepackage[absolute]{textpos}
|
||||
% \usepackage[UKenglish]{isodate}
|
||||
\setlength{\TPHorizModule}{1mm}
|
||||
\setlength{\TPVertModule}{1mm}
|
||||
\newcommand{\lastupdated}{\begin{textblock}{60}(155,5)
|
||||
@@ -57,7 +53,6 @@ Last Updated on \today
|
||||
}
|
||||
|
||||
% Section seperators
|
||||
\usepackage{titlesec}
|
||||
\titlespacing{\section}{0pt}{0pt}{0pt}
|
||||
\titlespacing{\subsection}{0pt}{0pt}{0pt}
|
||||
\newcommand{\sectionsep}{\vspace{8pt}}
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
# Custom Templates
|
||||
|
||||
This folder holds user-registered LaTeX templates, managed by the `/add-template` command. The framework works out of the box with its stock templates (moderncv for CVs, `cover.cls` for cover letters) — this folder only gets content when you register your own.
|
||||
This folder holds user-registered templates (LaTeX, Typst, or any other toolchain with a declared compile command), managed by the `/add-template` command. The framework works out of the box with its stock templates (moderncv for CVs, `cover.cls` for cover letters) — this folder only gets content when you register your own.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -8,9 +8,9 @@ This folder holds user-registered LaTeX templates, managed by the `/add-template
|
||||
templates/
|
||||
├── cv/
|
||||
│ └── <template-name>/
|
||||
│ ├── template.tex # Profile-agnostic skeleton ([PLACEHOLDER] tokens)
|
||||
│ ├── TEMPLATE.md # Manifest: engine, fonts, page limit, style rules, pitfalls
|
||||
│ ├── *.cls / *.sty # Custom class/style files (if the template needs them)
|
||||
│ ├── template.<ext> # Profile-agnostic skeleton ([PLACEHOLDER] tokens), e.g. template.tex or template.typ
|
||||
│ ├── TEMPLATE.md # Manifest: source extension, compile command, fonts, page limit, style rules, pitfalls
|
||||
│ ├── *.cls / *.sty # Custom class/style files, or Typst packages (if the template needs them)
|
||||
│ └── fonts/ # Bundled font files (if not using system fonts)
|
||||
└── cover_letters/
|
||||
└── <template-name>/
|
||||
@@ -19,8 +19,8 @@ templates/
|
||||
|
||||
## How it works
|
||||
|
||||
- `/add-template` interviews you for the template's instructions (compile engine, fonts, style rules, page limit), stores the files here, and runs a mandatory test compile before registering anything.
|
||||
- Activating a template adds a managed block to `05-cv-templates.md` or `06-cover-letter-templates.md`, which is what `/apply` reads when drafting — no other wiring needed.
|
||||
- `/add-template` interviews you for the template's instructions (source extension, compile command, fonts, style rules, page limit), stores the files here, and runs a mandatory test compile before registering anything.
|
||||
- Activating a template adds a managed block to `05-cv-templates.md` or `06-cover-letter-templates.md`, which is what `/apply` reads when drafting and compiling — no other wiring needed.
|
||||
- `/add-template --list` shows registered templates; `/add-template --use <name>` switches; `/add-template --use default` reverts to the stock templates.
|
||||
|
||||
Templates are stored with `[PLACEHOLDER]` tokens instead of personal data, so they are safe to commit and share.
|
||||
|
||||
@@ -157,5 +157,46 @@ class DetectColumnTypeTests(unittest.TestCase):
|
||||
self.assertEqual(companies[0]["categories"]["salary_index"], {"index": 105.5})
|
||||
|
||||
|
||||
def test_parse_sheet_pairs_interleaved_count_index_columns_by_name(self):
|
||||
ws = FakeWorksheet([
|
||||
("Company", "Antal kvinder", "Antal mænd", "Kvinder indeks", "Mænd indeks"),
|
||||
("Example Corp", 15, 20, 95.0, 108.0),
|
||||
])
|
||||
|
||||
companies = parse_sheet(ws)
|
||||
|
||||
categories = companies[0]["categories"]
|
||||
self.assertEqual(categories["kvinder"], {"count": 15, "index": 95.0})
|
||||
self.assertEqual(categories["mænd"], {"count": 20, "index": 108.0})
|
||||
|
||||
def test_standalone_count_column_is_stored_as_count_not_index(self):
|
||||
# A count column with no matching index column (e.g. a lone total
|
||||
# headcount) is still count data. It must not be emitted as a salary
|
||||
# index, which salary_lookup would render with a bogus "vs baseline"
|
||||
# percentage. The paired category alongside it is unaffected.
|
||||
ws = FakeWorksheet([
|
||||
("Company", "Antal", "IT Count", "IT Index"),
|
||||
("Example Corp", 250, 30, 108.5),
|
||||
])
|
||||
|
||||
companies = parse_sheet(ws)
|
||||
|
||||
categories = companies[0]["categories"]
|
||||
self.assertEqual(categories["antal"], {"count": 250})
|
||||
self.assertEqual(categories["it"], {"count": 30, "index": 108.5})
|
||||
|
||||
def test_parse_sheet_non_adjacent_columns_no_cross_match(self):
|
||||
ws = FakeWorksheet([
|
||||
("Company", "Count_A", "Count_B", "Index_A", "Index_B"),
|
||||
("Example Corp", 10, 20, 100.0, 200.0),
|
||||
])
|
||||
|
||||
companies = parse_sheet(ws)
|
||||
|
||||
categories = companies[0]["categories"]
|
||||
self.assertEqual(categories["a"], {"count": 10, "index": 100.0})
|
||||
self.assertEqual(categories["b"], {"count": 20, "index": 200.0})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -28,6 +28,7 @@ FRAMEWORK_FILES = [
|
||||
".claude/skills/job-application-assistant/05-cv-templates.md",
|
||||
".claude/skills/job-application-assistant/06-cover-letter-templates.md",
|
||||
".claude/skills/job-application-assistant/07-interview-prep.md",
|
||||
".claude/skills/job-application-assistant/08-application-forms.md",
|
||||
".claude/skills/job-application-assistant/SKILL.md",
|
||||
"AGENTS.md",
|
||||
]
|
||||
|
||||
@@ -132,62 +132,71 @@ def parse_sheet(ws, sheet_label=None):
|
||||
continue
|
||||
data_cols.append((i, h))
|
||||
|
||||
# Try to detect paired count/index columns per category
|
||||
# Heuristic: if columns come in pairs and alternate count/index, group them
|
||||
categories = []
|
||||
i = 0
|
||||
while i < len(data_cols):
|
||||
col_idx, col_header = data_cols[i]
|
||||
# Group data columns by detected type and derive category names
|
||||
count_cols = []
|
||||
index_cols = []
|
||||
untyped_cols = []
|
||||
|
||||
for col_idx, col_header in data_cols:
|
||||
col_type = detect_column_type(col_header)
|
||||
|
||||
if i + 1 < len(data_cols):
|
||||
next_col_idx, next_col_header = data_cols[i + 1]
|
||||
next_col_type = detect_column_type(next_col_header)
|
||||
|
||||
# If we have a count/index pair, group them
|
||||
if col_type == "count" and next_col_type == "index":
|
||||
# Use the header minus the count/index suffix as category name
|
||||
if col_type == "count":
|
||||
cat_name = strip_type_patterns(col_header, COUNT_PATTERNS)
|
||||
if not cat_name:
|
||||
cat_name = f"category_{len(categories)+1}"
|
||||
else:
|
||||
cat_name = cat_name.replace(" ", "_").replace("-", "_")
|
||||
categories.append({
|
||||
"name": cat_name,
|
||||
"count_col": col_idx,
|
||||
"index_col": next_col_idx,
|
||||
})
|
||||
i += 2
|
||||
continue
|
||||
elif col_type == "index" and next_col_type == "count":
|
||||
count_cols.append((col_idx, col_header, cat_name))
|
||||
elif col_type == "index":
|
||||
cat_name = strip_type_patterns(col_header, INDEX_PATTERNS)
|
||||
if not cat_name:
|
||||
cat_name = f"category_{len(categories)+1}"
|
||||
index_cols.append((col_idx, col_header, cat_name))
|
||||
else:
|
||||
cat_name = cat_name.replace(" ", "_").replace("-", "_")
|
||||
untyped_cols.append((col_idx, col_header))
|
||||
|
||||
# Pair count/index columns by matching category name
|
||||
categories = []
|
||||
used_counts = set()
|
||||
used_indexes = set()
|
||||
|
||||
for ci, (c_idx, c_header, c_cat) in enumerate(count_cols):
|
||||
for ii, (i_idx, i_header, i_cat) in enumerate(index_cols):
|
||||
if ii in used_indexes:
|
||||
continue
|
||||
if c_cat and i_cat and c_cat == i_cat:
|
||||
cat_name = c_cat.replace(" ", "_").replace("-", "_")
|
||||
categories.append({
|
||||
"name": cat_name,
|
||||
"index_col": col_idx,
|
||||
"count_col": next_col_idx,
|
||||
"count_col": c_idx,
|
||||
"index_col": i_idx,
|
||||
})
|
||||
i += 2
|
||||
continue
|
||||
used_counts.add(ci)
|
||||
used_indexes.add(ii)
|
||||
break
|
||||
|
||||
# Single column - treat as a standalone value
|
||||
categories.append({
|
||||
"name": col_header.lower().replace(" ", "_"),
|
||||
"value_col": col_idx,
|
||||
})
|
||||
i += 1
|
||||
# Remaining unmatched count columns become standalone. They are still count
|
||||
# data, so tag them as such — otherwise a lone headcount would be emitted as
|
||||
# a salary index and rendered with a meaningless "vs baseline" percentage.
|
||||
for ci, (c_idx, c_header, _) in enumerate(count_cols):
|
||||
if ci not in used_counts:
|
||||
categories.append(
|
||||
{"name": c_header.lower().replace(" ", "_"), "value_col": c_idx, "field": "count"}
|
||||
)
|
||||
|
||||
# Remaining unmatched index columns become standalone (use original header)
|
||||
for ii, (i_idx, i_header, _) in enumerate(index_cols):
|
||||
if ii not in used_indexes:
|
||||
categories.append({"name": i_header.lower().replace(" ", "_"), "value_col": i_idx})
|
||||
|
||||
# Untyped columns become standalone
|
||||
for col_idx, col_header in untyped_cols:
|
||||
categories.append({"name": col_header.lower().replace(" ", "_"), "value_col": col_idx})
|
||||
|
||||
# Parse data rows
|
||||
companies = []
|
||||
for row in ws.iter_rows(min_row=header_row + 1, values_only=True):
|
||||
if not row[company_col]:
|
||||
if company_col >= len(row) or not row[company_col]:
|
||||
continue
|
||||
|
||||
company_name = str(row[company_col]).strip()
|
||||
city_name = str(row[city_col]).strip() if city_col is not None and row[city_col] else ""
|
||||
if city_col is not None and city_col < len(row) and row[city_col]:
|
||||
city_name = str(row[city_col]).strip()
|
||||
else:
|
||||
city_name = ""
|
||||
|
||||
entry = {
|
||||
"company": company_name,
|
||||
@@ -224,7 +233,8 @@ def parse_sheet(ws, sheet_label=None):
|
||||
# Non-numeric standalone value (e.g. a free-text "Notes"
|
||||
# column) is not salary data; skip it for this row.
|
||||
continue
|
||||
entry["categories"][cat_name] = {"index": val}
|
||||
field = cat.get("field", "index")
|
||||
entry["categories"][cat_name] = {field: int(val) if field == "count" else val}
|
||||
|
||||
companies.append(entry)
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@ REQUIRED_IGNORE_RULES = [
|
||||
# to its own directory, so the state file lands under .claude/skills/... and
|
||||
# a repo-rooted rule silently fails to match it.
|
||||
"**/job_scraper/seen_jobs.json",
|
||||
"cv/main_*.tex",
|
||||
"cv/main_*.*",
|
||||
"!cv/main_example.tex",
|
||||
"cover_letters/cover_*.tex",
|
||||
"cover_letters/cover_*.*",
|
||||
"documents/cv/**",
|
||||
"documents/linkedin/**",
|
||||
"documents/diplomas/**",
|
||||
|
||||
+3
-1
@@ -22,7 +22,9 @@ def run_tool(command):
|
||||
).stdout
|
||||
except FileNotFoundError as exc:
|
||||
raise VerificationError(
|
||||
f"required command '{command[0]}' was not found; install poppler-utils"
|
||||
f"required command '{command[0]}' was not found. "
|
||||
"Install poppler-utils (macOS: brew install poppler, "
|
||||
"Debian/Ubuntu: apt install poppler-utils, Windows: choco install poppler)"
|
||||
) from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
detail = (exc.stderr or "").strip() or (exc.stdout or "").strip()
|
||||
|
||||
Reference in New Issue
Block a user