Skip to content

xantorres/shrike

Repository files navigation

shrike

A remote job search generates far more noise than signal: alert digests, recruiter spam, reposts of the same role, US-only listings that read "Remote." shrike ingests that stream, kills what doesn't fit with a reason attached, and tracks the rest to outcome — so every role is either dropped fast or followed up on, never lost in an inbox.

Named for the butcher-bird: it strikes fast and keeps a visible larder of every catch, returning until each one is finished.

Why shrike exists

Most job trackers are passive — a spreadsheet you update by hand. shrike is an active filter first and a tracker second: it rejects roles that don't match your stack, region, or comp floor before they ever reach a list, collapses reposts of the same job into one entry, and won't let a reply silently close an application unless the classifier is actually confident. Everything runs locally against one SQLite file — no server, no account, no telemetry.

  • Ingest job mail from Gmail (read-only OAuth) and Work At A Startup dumps
  • Filter hard with a configurable profile: junk titles, off-stack roles, region locks, compensation floors
  • Triage the survivors in a keyboard TUI (shortlist / ignore / snooze)
  • Track applications with due follow-ups; replies auto-classify (interview / assignment / offer / rejection) with guardrails
  • Everything local: one SQLite file, secrets in the macOS keychain, no server, no telemetry

What a scan looks like

$ shrike scan --verbose
14 new, 6 excluded, 2 dupes, 0 suppressed, 3 synced (1 rejected, 1 interview), 0 unmatched
  excluded: TalentBridge — Virtual Assistant (banned-title:virtual assistant)
  excluded: Meridian Labs — Senior Frontend Engineer (region-locked:US)
  excluded: Forgepoint — React Native Developer (off-profile-discipline:react native)
  excluded: Anchorstone — Fullstack Engineer ($22/hr) (cheap-hourly-rate:$22/hr)

Every drop carries the exact rule that fired — nothing gets excluded on a hunch, and every reason is greppable back to the config or filter code that produced it.

Built for

Remote engineers running a high-volume search across Gmail alerts, LinkedIn digests, YC/Work At A Startup, and direct recruiter mail — particularly contractors and B2B/EOR candidates who need region and comp filtering a generic job board doesn't offer, and who'd rather own their pipeline data than hand it to a SaaS.

Install

With Bun ≥ 1.1:

git clone https://github.com/xantorres/shrike && cd shrike
bun install && bun link

Or grab a compiled binary from Releases (macOS arm64/x64, Linux x64) — no runtime required.

Quickstart

shrike init            # scaffold ~/.config/shrike/config.json
shrike auth            # authorize Gmail (readonly), store refresh token in keychain
shrike scan            # ingest job mail, filter, sync replies
shrike triage          # keyboard triage of new opportunities
shrike apply 12        # record an application for opportunity 12
shrike next            # what's due today

Works without Gmail too: shrike add --company X --role Y tracks anything manually.

Commands

shrike init                            scaffold the config file
shrike scan [--verbose]                fetch gmail, ingest, filter, sync replies
shrike add [url] --company <c> --role <r> [--location --rate --engagement --dossier]
shrike import <path.md> [--due-in 7]   import a markdown pipeline table
shrike triage                          interactive; --json lists pending
shrike decide <opp-id> <verdict>       ignore | shortlist | apply_intent | snooze
shrike apply <opp-id> [--channel --cv --next --due --on]
shrike list [--all]                    applications, active or all
shrike update <app-id> [--stage --status --next --due --note]
shrike next                            due and overdue actions
shrike show <app|opp> <id>             full record with events and decisions
shrike search <query>                  fuzzy match across companies/roles/aliases
shrike note <opp|app|contact> <id> "text"
shrike alias add|list <opp-id>         alternate company names for matching
shrike intents                         apply-intent decisions not yet applied
shrike export [--out <path>]           one-way markdown snapshot
shrike waas                            ingest Work At A Startup JSON from stdin
shrike apply-queue [--out path]        click-to-apply HTML page of pending jobs
shrike discover [--titles --locations --exclude --ats --since 30]
                                       ATS X-ray searches + freelance launcher
shrike auth                            (re-)authorize Gmail OAuth

Every command accepts --json. The database lives at ~/.local/share/shrike/shrike.db (override with SHRIKE_DB).

Configuration

~/.config/shrike/config.json (override with SHRIKE_CONFIG); see config.example.json for the full shape. Every section is optional — a check whose section is absent is off.

{
  "gmail": {
    "clientId": "<oauth-client-id>.apps.googleusercontent.com",
    "clientSecretRef": "keychain:shrike.gmail.client",
    "refreshTokenRef": "keychain:shrike.gmail.refresh"
  },
  "filter": {
    "profile": {},
    "region": { "eligibleCodes": ["PT"] },
    "comp": { "annualFloorK": 60, "monthlyFloorK": 5, "hourlyFloor": 40 }
  },
  "discover": {
    "titles": ["Frontend Engineer", "React Developer"],
    "locations": ["Remote", "Portugal", "EU"]
  }
}
  • filter.profile — stack filtering. {} enables the built-in example profile (React/TypeScript web); set onProfileSignals / offProfileSignals to describe your own stack. Absent = no stack filtering.
  • filter.region — region filtering for remote candidates. eligibleCodes is your home country; roles locked to other single countries, US-onsite listings, and US-work-authorization requirements get dropped. Broad-region listings (EU/EMEA/worldwide) are kept. Absent = no region filtering.
  • filter.comp — drop listings whose stated pay is under your floor (annual K, monthly K, hourly). Absent = no comp filtering.
  • filter.bannedTitles / filter.bannedLocations — replace the built-in junk-title and onsite-location lists.
  • gmail.clientSecretRef — secret refs are keychain:<service>, env:<VAR>, or literal:<value>. Keychain refs read security find-generic-password -a $USER -s <service> -w.

Gmail setup

  1. Create an OAuth client (type "Desktop app") in Google Cloud Console with the Gmail API enabled, and paste its client id into the config.
  2. Store the client secret: security add-generic-password -U -a "$USER" -s shrike.gmail.client -w
  3. Run shrike auth — it opens the consent screen, catches the loopback callback, and stores the refresh token in your keychain. Scope is gmail.readonly; shrike never modifies your mail.

Refresh tokens for OAuth clients still in "Testing" publishing status expire after 7 days; publish the client to "In production" to stop the weekly re-auth.

How filtering decides

Order per candidate row: banned titles → banned/region-locked locations → US-work-authorization body phrases → region lock (Remote (XX) country sets; pan-EU sets with ≥4 EU/EEA members are kept) → stack profile → engineering role check → comp floors. The first hit drops the row and is recorded as the exclusion reason (shrike scan --verbose shows them all).

Replies never close an application on a hunch: rejection detection requires explicit rejection context, positive kinds win ties, mail older than your last manual update is ignored, and ghosted applications are only ever revived by you (shrike update <id> --status active --next "..." --due ...).

Scheduling

Run a daily scan on macOS with the launchd template:

cp docs/ops/shrike.scan.plist ~/Library/LaunchAgents/com.example.shrike.scan.plist
launchctl load ~/Library/LaunchAgents/com.example.shrike.scan.plist

(Edit the two REPLACE-ME paths in the plist first.) Logs go to /tmp/shrike-scan.log and /tmp/shrike-scan.err.

Development

bun install
bun test          # ~380 tests, in-memory SQLite, no network
bun run typecheck
bun run lint
bun run build:bin # compile a standalone binary to dist/shrike

Architecture in one paragraph: core/ is pure logic (filtering, matching, reply classification, fingerprint dedupe), db/ is a per-table repository layer over bun:sqlite with snapshot-plus-migrations schema management, sources/ are adapters (Gmail with injected fetch, Work At A Startup), services/ compose those into use-cases (ingest, expiry, application changes), and cli/ is a thin command layer — the interactive triage TUI is a pure reducer with an IO shell. All external input (config, Gmail API responses, stdin payloads) is Zod-validated at the boundary. More in docs/design.md.

License

MIT

About

Local-first CLI CRM for the job search — ingest, filter hard, triage, track to outcome.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages