A clean, docs-style web interface for the career-ops AI job-search pipeline. Search, evaluate, deep-dive, apply, and track every offer from a single browser tab — instead of bouncing between Claude Code, terminals, and markdown files.
English | Español | Português (Brasil) | 한국어 | 日本語 | Русский | 简体中文 | 繁體中文 | Français
🆕 Latest release — v1.68.1
1. Reworked
#/scanresult-filter panel. Every filter now has a label above it (Search · Work type · Salary from / to · Source · Scope), explicit Apply + Reset buttons, and an on-page hint explaining how to use it. Work type adds an On-site option (Remote / Hybrid / On-site / Relocation).2. The salary filter now actually filters. Set a from / to and the list keeps only jobs whose pay overlaps your range — jobs whose pay falls outside it and jobs with no listed salary at all are dropped (overlapping-range match; currency ignored).
3. Per-source scan fetch timeout raised to 60s (override with
SCAN_FETCH_TIMEOUT_MS). 10s failed fast but cut off slow-but-alive boards; one minute lets them return. Trade-off: a dead source now holds a slot for the full minute, and chronic hangers may still time out.Full suite 1063/1063 green · i18n + docs synced across all 9 locales.
career-ops is an open-source job-search system that runs as slash commands inside any AI coding CLI (Claude Code, Codex, OpenCode, Qwen CLI — other Claude-compatible CLIs work too via the same slash-command surface). Model-agnostic. It evaluates each posting against your CV with a six-dimension 0.0–5.0 rubric, generates tailored PDF résumés, and tracks every application locally — no cloud accounts, no telemetry, no auto-submit.
This repository (career-ops-ui) is a polished web interface on top. The CLI keeps owning form-fill (via Playwright MCP) and slash-command modes; the SPA gives you a CRM-style browser surface over the same cv.md / data/applications.md / reports/ files. Both share the same data.
Action thresholds by score (from career-ops.org/docs):
| Score | Next step |
|---|---|
| ≥ 4.5 | /career-ops apply — high fit, push immediately |
| 4.0 – 4.4 | apply, or /career-ops contacto for warm intro |
| 3.5 – 3.9 | /career-ops deep — research first |
| < 3.5 | skip unless you have a specific reason |
Canonical guides at career-ops.org/docs:
Important — career-ops-ui is a dashboard on top of
santifer/career-ops. It runs inside a career-ops project ascareer-ops/web-ui/and reads yourcv.md,config/,data/from the parent folder via../. It does not work standalone — you need the parentcareer-opsrepo too. Don't clone it on its own and runinit; use one of the two options below.
curl -fsSL https://raw.githubusercontent.com/Fighter90/career-ops-ui/main/bin/setup.sh | bashClones both repos, arranges the career-ops/web-ui/ layout, installs deps, runs the doctor, and starts the server at http://127.0.0.1:4317 — then opens the dashboard.
If you already have career-ops configured and just want the dashboard, clone the UI inside it as web-ui:
cd career-ops # ← your existing career-ops project
git clone https://github.com/Fighter90/career-ops-ui.git web-ui
cd web-ui
npm install
npx career-ops-ui init # interactive: pick LLM provider + paste its key → parent career-ops/.envThe nested web-ui/ layout is exactly what lets the UI resolve your ../cv.md, ../config/, ../data/. Run npm link once if you'd rather type the bare career-ops-ui <verb> instead of npx career-ops-ui <verb>.
career-ops-ui setup # bootstrap: install deps → doctor → run (SKIP_START=1 to stop before run)
career-ops-ui init # pick LLM provider + paste its key (interactive)
career-ops-ui doctor # verify Node / project / keys / Playwright (exit 0 ⇔ all required green)
career-ops-ui run # launch the server at http://127.0.0.1:4317
career-ops-ui open # open + RAISE the dashboard tab in your browser
career-ops-ui help # list every verbPrefix with npx (e.g. npx career-ops-ui run) if you didn't npm link. After setup/run the tab opens and is brought to the front automatically; set NO_OPEN=1 to disable auto-open (headless / CI).
init is the provider wizard — choose Claude / Claude Code (ANTHROPIC_API_KEY), Gemini / Gemini CLI (GEMINI_API_KEY), Codex / OpenCode CLI (OPENAI_API_KEY), or Auto (Anthropic → Gemini fallback). Keys are typed with echo suppressed and written to the parent career-ops/.env via the same validated path as the #/config API-keys tab. Non-interactive form for CI:
career-ops-ui init --provider claude --anthropic-key sk-ant-… --yes
career-ops-ui init --provider gemini --gemini-key … --yes
career-ops-ui init --provider auto --openai-key sk-… --yesOr set it by hand: echo "ANTHROPIC_API_KEY=sk-ant-…" >> career-ops/.env. The provider sets LLM_PROVIDER (auto | claude | gemini); change it any time from #/config → API keys without restarting.
If career-ops-ui init fails or the command isn't found (common right after a git pull):
cd career-ops/web-ui
npm install
npx career-ops-ui init # npx runs the local bin even without `npm link`Make sure:
- You're running it from inside
career-ops/web-ui/— not from a standalonecareer-ops-ui/clone. - The parent
career-ops/folder exists and containscv.mdandconfig/. If you cloned career-ops-ui on its own, move it (or re-clone) so it sits atcareer-ops/web-ui/— or just run Option 1's curl, which arranges the layout for you. career-ops-ui doctor(ornpx career-ops-ui doctor) prints exactly what's missing.
career-ops is a powerful Claude-Code-driven job-search system: paste a JD → get a 0-5 fit score, an ATS-optimized PDF, and a tracker entry. It works great inside Claude Code, but the data lives across cv.md, data/applications.md, reports/*.md, data/pipeline.md, portals.yml, config/profile.yml — easy to lose, hard to skim.
career-ops-ui puts a polished UI on top:
- Auto-pipeline — paste one job URL on
#/auto, click once: validate → fetch → evaluate → save report → add to tracker, with a live a11y stepper and artifact deep-links. - Browse the tracker, reports, and pipeline like a CRM.
- Trigger scans (Greenhouse / Ashby / Lever / Workable / SmartRecruiters / Workday and hh.ru / Habr Career / Trudvsem / GetMatch / GeekJob) and watch live SSE logs.
- Evaluate a JD live via Anthropic (preferred) or Gemini, or get a copy-paste prompt for Claude Code if no API key is set.
- Deep research companies live via Anthropic SDK with cv / profile / mode files inlined automatically.
- Edit
cv.mdwith side-by-side markdown preview and server-side XSS sanitization. - Maintain the system: doctor, verify, normalize, dedup, merge — one click each.
- Multi-CLI: drives identically from Claude Code, Codex, Cursor, Aider, or Gemini CLI —
CLAUDE.md/AGENTS.md/GEMINI.mdshims point to a single source of truth.
It's pure additions: nothing inside career-ops/ changes. All your customizations stay yours.
git clone https://github.com/santifer/career-ops.git
cd career-opsFollow career-ops onboarding so cv.md, config/profile.yml, and portals.yml exist.
git clone https://github.com/Fighter90/career-ops-ui.git web-uiYour tree now looks like:
career-ops/
├─ cv.md
├─ portals.yml
├─ config/
├─ data/
├─ modes/
├─ reports/
├─ scan.mjs … doctor.mjs … (etc)
└─ web-ui/ ← this repo
├─ bin/start.sh
├─ package.json
├─ server/
├─ public/
└─ tests/
bash web-ui/bin/start.shThe script:
- Checks Node ≥ 18.
npm install(only on first run, three deps — Express + js-yaml + multer).- Starts the Express server on
127.0.0.1:4317. - Opens http://127.0.0.1:4317/ in your default browser.
Custom port / host:
PORT=8080 bash web-ui/bin/start.sh
HOST=0.0.0.0 PORT=4317 bash web-ui/bin/start.sh # expose on LANIf you cloned the repo somewhere else (not as career-ops/web-ui), point at career-ops via env:
CAREER_OPS_ROOT=/path/to/career-ops bash bin/start.shcareer-ops/data/pipeline.md ships with two QA fixture URLs (example.com/qa-fixture-*) so the test suite can run hermetically. On a fresh clone you'll see Pipeline showing 2 pending — those are not real jobs. Purge them before your first scan:
make clean-test-fixtures # removes pipeline.md fixture rows + qa-fixture-* applications.md rows
npm startOpen http://127.0.0.1:4317. Pipeline counter should now read 0 pending. The Makefile is idempotent — re-running it on a clean tree is a no-op.
| Node.js | ≥ 18 (uses native fetch, node:test) |
| career-ops | Cloned and onboarded — see above |
| Optional | GEMINI_API_KEY in .env of the parent project (free-tier model gemini-2.0-flash) for one-click JD evaluation. Otherwise the UI returns a copy-paste prompt for Claude. |
| Optional | Run from a Russian IP / VPN if hh.ru returns 403. Habr Career works from any IP regardless. |
| Optional | Playwright (already a transitive dep of career-ops) for the e2e test suite. |
| Page | What it does |
|---|---|
| Dashboard | Aggregated counts (apps / pipeline / reports), avg score, status breakdown, latest 5 apps + latest report. |
| Scan | 🌐 Single Scan button — runs every enabled source in one go (Greenhouse / Ashby / Lever / Workable / SmartRecruiters / Workday for EN, hh.ru + Habr Career + Trudvsem + GetMatch + GeekJob for RU). Live SSE log streaming + clickable results table with location / Remote-Hybrid badge / relocation flag / salary / source filters and dynamic stack / level / keyword chips. Active-Companies card lists every tracked board with its API health. |
| Pipeline | CRUD on data/pipeline.md. Server-side preview proxy (SSRF-safe, per-hop redirect validation, 8 KB body cap). Jump straight from a URL to evaluate. |
| Evaluate | Paste JD → Anthropic-first (preferred when both keys present), then Gemini, then manual prompt fallback. Anthropic path inlines cv / profile / _shared.md / oferta.md automatically (REVIEW-A1). Save JD to jds/ optional. |
| Deep research | Same fallback chain as Evaluate. Live Anthropic returns ~10-30 KB of grounded markdown saved to interview-prep/<company>-<role>.md. |
| Modes | 7 generic mode pages (/#/project, /#/training, /#/followup, /#/batch, /#/contacto, /#/interview-prep, /#/patterns) with the same Anthropic / Gemini / manual fallback. |
| Apply helper | Generates a submission checklist; the actual Playwright form-fill stays in /career-ops apply inside Claude Code. |
| Tracker | Filterable table over data/applications.md (status, score, free-text). One-click normalize-statuses.mjs / dedup-tracker.mjs / merge-tracker.mjs. Pipe + newline escapes are GFM-compliant — names like "Acme | Co" round-trip losslessly. |
| Reports | Browse and read every report under reports/ with parsed header (Score / Legitimacy / URL). |
| CV | Live markdown editor for cv.md with side-by-side preview + one-click cv-sync-check.mjs + 📁 Upload CV. Server-side XSS strip on save (<script>, javascript:, on*= handlers). |
| Profile | Read-only view of config/profile.yml + archetypes — UI-friendly summary. |
| App settings | In-UI editor for parent .env keys: ANTHROPIC_API_KEY, GEMINI_API_KEY, model overrides, port / host. Secrets masked on read. |
| Health | All setup checks in OK / OPTIONAL / FAIL badges + buttons to run doctor.mjs and verify-pipeline.mjs. |
| Help | In-app Markdown user guide (/#/help), localized for all 9 supported languages (en / es / fr / pt-BR / ko-KR / ja / ru / zh-CN / zh-TW). |
| Activity log | Audit trail of every state-changing request (writes, runs, scans). Secrets redacted. |
| Notifications 🔔 (v1.58.34 / v1.58.35) | Top-bar bell with red unread badge. Click to slide-in a drawer listing the last 50 toasts (per tab, per session) — Success / Error / Info-progress, each with a localized timestamp, the human message, and any (METHOD /path · HTTP NNN) postfix tucked into a <details>. Help §18 documents every category. Drawer opens only on bell click (or keyboard Enter / Space); closes via ×, Esc, or re-clicking the bell. |
Global keyboard shortcuts:
Ctrl+K/Cmd+K— focus the global search. The footer hint adapts per platform (⌘K on macOS/iOS, Ctrl+K elsewhere) with the localized verb (v1.58.20).- Pasting a URL into global search auto-adds it to the pipeline.
Esc— close any open modal or the notifications drawer (v1.58.34).
Zero-token portal scanning that actually returns vacancies. One 🌐 Scan button in the UI runs every configured source in a single sweep:
- Greenhouse / Ashby / Lever / Workable / SmartRecruiters / Workday — public boards-api for every company in
portals.yml::tracked_companieswith a recognizable ATS pattern. Bundled list covers Stripe, GitLab, Vercel, Cloudflare, Datadog, Discord, Elastic, Grafana Labs, CockroachDB, Fastly, Twilio, Coinbase, Reddit, Robinhood, Affirm, Lyft, Linear, Supabase, PostHog, Ramp, Modal Labs, Railway, Browserbase, JetBrains — extend or trim freely. - RSS boards — any job board that exposes an RSS/Atom feed (LaraJobs, WeWorkRemotely, RemoteOK, golangprojects, …). Add
provider: rss+ the feed URL toportals.yml— no code changes required. - hh.ru — HTML scrape of
hh.ru/search/vacancy. Works from any IP, no key, no proxy. (The JSON APIapi.hh.ruis not used — it now 403s every programmatic client regardless of IP/User-Agent; the website serves full results to any browser-like client, so we scrape that, the same way Habr Career is scraped.) - Habr Career — HTML scrape of
career.habr.com/vacancies. Works from any IP, no auth.
Point the scanner at any RSS-based job board by adding an entry with provider: rss and an rss: (or feed_url:) key to portals.yml:
tracked_companies:
- name: LaraJobs
provider: rss
rss: https://larajobs.com/feed
enabled: true
- name: WeWorkRemotely
provider: rss
rss: https://weworkremotely.com/remote-jobs.rss
enabled: trueThe adapter parses <item> blocks using a tiny regex-based parser (no XML library needed). It extracts title, link (→ url), pubDate (→ date), and description (→ snippet, HTML stripped). Remote status is inferred from /remote|anywhere/i in the title or description; company name is pulled from dc:creator, a "Company — Role" title pattern, or the feed hostname as a fallback. The same normalize → filter → dedup → pipeline-append flow applies as for ATS adapters.
All sources go through the same pipeline: normalize → filter (title_filter.positive / title_filter.negative) → dedup against data/scan-history.tsv + data/pipeline.md + data/applications.md → append to data/pipeline.md → save full result set to data/last-scan.json for the UI's filterable table.
Configure via portals.yml:
title_filter:
positive: [backend, engineer, senior, tech lead, golang, php]
negative: [junior, intern, frontend, ios, android]
tracked_companies:
- { name: Stripe, enabled: true, careers_url: https://job-boards.greenhouse.io/stripe }
- { name: Linear, enabled: true, careers_url: https://jobs.ashbyhq.com/linear }
# ...
russian_portals:
sources: ["hh", "habr"] # one or both
area: 113 # 1=Moscow, 2=SPb, 113=Russia, 1001=remote
per_page: 50
only_remote: false
queries: ["Senior PHP", "Senior Go", "Tech Lead"]All sources flow through a single SSE endpoint: /api/stream/scan?source=ats|regional|both. The 🌐 Scan UI button calls source=both so every adapter (Greenhouse / Ashby / Lever / Workable / SmartRecruiters / Workday + hh.ru + Habr Career + Trudvsem + GetMatch + GeekJob) runs in one connection. Honors AbortSignal on client disconnect — no orphan fetches.
career-ops-ui/
├─ CLAUDE.md # project-level agent instructions (canonical)
├─ AGENTS.md # Codex / Aider / generic CLI shim → CLAUDE.md
├─ GEMINI.md # Gemini CLI shim → CLAUDE.md
├─ .aiignore # exclusion list for AI tools
├─ .claude/ # Claude Code agent config
│ ├─ agents/ # 3 project-specific subagents (route, view, test isolation)
│ └─ commands/ # slash-command stubs
├─ bin/start.sh # one-shot launcher (Node check → npm install → server → open browser)
├─ package.json # 2 runtime deps: express, js-yaml
├─ server/
│ ├─ index.mjs # ~130 LOC orchestrator: middleware + 12 register<Topic>Routes(app) calls + SPA catch-all
│ └─ lib/
│ ├─ paths.mjs # absolute paths to career-ops files (CAREER_OPS_ROOT aware)
│ ├─ parsers.mjs # markdown / pipeline / report parsers (GFM-compliant pipe escapes)
│ ├─ runner.mjs # runNodeScript() + streamNodeScript() with SIGTERM→SIGKILL escalation + 30 min cap
│ ├─ security.mjs # isValidJobUrl, stripDangerousMarkdown, sanitizeJobDescription, isPubliclyExposed
│ ├─ prompts.mjs # bundleProjectContext, buildEvaluationPrompt, buildDeepPrompt, buildModePrompt
│ ├─ store.mjs # safeReadApps/Pipeline/Reports, checkProfileCustomized, ensureRussianPortalsDefaults
│ ├─ anthropic.mjs # minimal Anthropic SDK adapter (runAnthropic, hasAnthropicKey, hasGeminiKey)
│ ├─ env-config.mjs # .env round-trip with secret masking + validation
│ ├─ activity-log.mjs # JSONL audit trail middleware (secrets redacted)
│ ├─ dotenv.mjs # tiny dotenv loader
│ ├─ en-scanner.mjs # in-process Greenhouse/Ashby/Lever orchestrator (AbortSignal aware)
│ ├─ ru-scanner.mjs # in-process hh.ru + Habr orchestrator (AbortSignal aware)
│ ├─ sources/
│ │ ├─ greenhouse.mjs # boards-api.greenhouse.io client
│ │ ├─ ashby.mjs # api.ashbyhq.com client
│ │ ├─ lever.mjs # api.lever.co client
│ │ ├─ hh.mjs # hh.ru/search/vacancy HTML scraper (paginated, UA-aware)
│ │ └─ habr.mjs # career.habr.com HTML parser (no cheerio, regex only)
│ └─ routes/ # 12 route modules — one per topic (P-2)
│ ├─ activity.mjs # /api/activity
│ ├─ config.mjs # /api/config (parent .env round-trip)
│ ├─ content.mjs # /api/cv, /api/profile, /api/portals, /api/modes
│ ├─ health.mjs # /api/health, /api/dashboard
│ ├─ help.mjs # /api/help/:lang
│ ├─ jds.mjs # /api/jds CRUD
│ ├─ llm.mjs # /api/evaluate, /api/deep, /api/mode/:slug, /api/apply-helper, /api/interview-prep*
│ ├─ pipeline.mjs # /api/pipeline + SSRF-safe preview proxy
│ ├─ reports.mjs # /api/reports
│ ├─ runners.mjs # /api/run/* + /api/stream/{scan,liveness,pdf} + /api/output/pdfs
│ ├─ scan.mjs # /api/stream/scan-{ru,en} + /api/scan-results
│ └─ tracker.mjs # /api/tracker
├─ public/ # static SPA — no build step
│ ├─ index.html
│ ├─ css/app.css # design tokens (docs-style palette)
│ └─ js/
│ ├─ api.js # fetch wrapper + connection-banner state + UI helpers + safe markdown renderer
│ ├─ router.js # hash-based router with 404 fallback + alias support
│ ├─ app.js # boot + global keyboard handlers + mobile sidebar drawer
│ ├─ lib/{i18n,skills}.js
│ └─ views/ # one file per page (dashboard, scan, pipeline, evaluate, deep, apply, tracker, reports, cv, settings, health, config, help, activity, mode-page)
├─ docs/ # public reference: architecture, API, data-flows, SDD, conventions, reviews
│ ├─ PROJECT.md # what / why / for-whom
│ ├─ ROADMAP.md # current milestone + completed history
│ ├─ PRODUCTION-READINESS.md # honest deployment-gate assessment
│ ├─ sdd/{SDD-GUIDE,CONVENTIONS}.md
│ ├─ architecture/{OVERVIEW,SERVER,FRONTEND,API,DATA-FLOWS}.md
│ └─ reviews/REVIEW-*.md
└─ tests/ # 1000 unit + 70 Playwright + 23/23 e2e:full + 20 e2e:smoke (baseline @ v1.60.0)
├─ parsers.test.mjs # markdown / pipeline / report parsers (pure functions)
├─ api.test.mjs # every endpoint, ephemeral server, no network
├─ {ru,en}-scanner.test.mjs # mocked fetch
├─ pipeline-preview.test.mjs # per-hop redirect validation (REVIEW-B1)
├─ anthropic.test.mjs # SDK adapter + log-guard test (REVIEW-B4)
├─ url-validation.test.mjs # SSRF reject sweep (FIX-M3 + M6 + M7)
├─ cv-xss.test.mjs # stripDangerousMarkdown round-trip
├─ jd-sanitize.test.mjs # sanitizeJobDescription
├─ help.test.mjs / help-ui.test.mjs # i18n parity across all 8 locales
├─ playwright-smoke.mjs # 12 browser flows (CV save, tracker, pipeline, evaluate, config, etc.)
└─ e2e{,-comprehensive}.mjs # full Playwright walkthrough
Vanilla HTML/CSS/JS keeps the surface area tiny: one npm install of two deps and you're running. No Webpack, no Vite, no node_modules of doom. The whole UI is < 30 KB minified. If you want hot-reload during development, npm run dev uses Node's built-in --watch.
Non-trivial changes go through the GSD pipeline (gsd-* skills from superpowers@claude-plugins-official):
discuss → spec → plan → execute → verify → review
Public reference: docs/sdd/SDD-GUIDE.md. All planning artifacts live under .planning/ (gitignored). The docs/ tree is the long-lived public contract.
All endpoints under /api/*. JSON in / JSON out unless noted.
| Method | Path | Response |
|---|---|---|
| GET | /api/health |
{ ok, warnings, version, parentVersion, checks: [{name, ok, required, value?}] } |
| GET | /api/dashboard |
{ counts, avgScore, byStatus, recent, pipeline, lastReport } |
| GET | /api/status/providers |
{ activeProvider, activeModel, keysConfigured } — LLM readiness for the onboarding banner + ⚡ cost hint (v1.55.3); includes openrouter (v1.57.0) |
| GET | /api/openrouter/models |
{ models:[{id,name,context_length}], fallback, cached } — OpenRouter catalogue proxy for the #/config model dropdown (v1.57.0) |
| GET | /api/activity?limit&type |
tail of data/activity.jsonl audit trail |
| GET | /api/help/:lang |
localized in-app user guide (fallback: en.md) |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/config |
known env keys with secrets masked |
| POST | /api/config |
validate + write parent .env; applies to process.env in-place |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/tracker |
{ rows: [parsed applications.md] } |
| POST | /api/tracker |
body { company, role, score?, status?, url?, notes?, date? } — dedup-aware (case-insensitive on company + role) |
| GET | /api/pipeline |
{ urls: [...] } |
| POST | /api/pipeline |
body { url } → adds to data/pipeline.md with dedup + isValidJobUrl |
| GET | /api/pipeline/preview?url=… |
server-side fetch proxy (per-hop SSRF check, ≤3 redirects, 8 KB cap) |
| DELETE | /api/pipeline?url=… |
removes a URL |
| GET | /api/reports |
parsed list of reports/*.md |
| GET | /api/reports/:slug |
full markdown + parsed header |
| GET | /api/jds |
list of saved JD files |
| GET | /api/jds/:name |
text/plain — raw JD |
| POST | /api/jds |
body { text, slug? } → saves to jds/ |
| DELETE | /api/jds/:name |
unlink (.txt suffix required) |
| GET | /api/cv |
{ markdown } |
| PUT | /api/cv |
body { markdown } → writes cv.md (XSS-stripped, ≤1 MB) |
| GET | /api/profile |
{ profile: yaml-parsed, raw: text } |
| GET | /api/portals |
{ portals: yaml-parsed, raw: text } |
| GET | /api/modes |
list of mode files |
| GET | /api/modes/:name |
text/plain — raw mode prompt |
| GET | /api/output/pdfs |
list of generated PDFs |
| GET | /api/output/pdfs/:name |
download (Content-Disposition: attachment) |
| GET | /api/interview-prep |
list of saved deep-research files |
| GET | /api/interview-prep/:name |
{ name, markdown } |
| DELETE | /api/interview-prep/:name |
unlink (.md suffix required) |
| Method | Path | Wraps |
|---|---|---|
| POST | /api/run/doctor |
node doctor.mjs |
| POST | /api/run/verify |
node verify-pipeline.mjs |
| POST | /api/run/normalize |
node normalize-statuses.mjs |
| POST | /api/run/dedup |
node dedup-tracker.mjs |
| POST | /api/run/merge |
node merge-tracker.mjs |
| POST | /api/run/sync-check |
node cv-sync-check.mjs |
All buffered runs cap at 60 s; SIGTERM → SIGKILL escalation after a 5 s grace period.
| Method | Path | Streams |
|---|---|---|
| GET | /api/stream/scan |
legacy node scan.mjs (subprocess) |
| GET | /api/stream/scan?source=ats|regional|both |
consolidated in-process scanner SSE — query: dryRun=1, company=… (ATS only). |
| GET | /api/stream/liveness |
node check-liveness.mjs |
| GET | /api/stream/pdf |
node generate-pdf.mjs |
SSE event types:
event: start data: { script, args?, writeFiles? }
event: log data: { stream: "stdout"|"stderr", line: string }
event: done data: { code, counts?, errors? }
event: error data: { message }
| Method | Path | Purpose |
|---|---|---|
| POST | /api/evaluate |
body { jd, save? } → JD evaluation (A–G sections per oferta.md) |
| POST | /api/evaluate/test-gemini |
smoke check GEMINI_API_KEY |
| POST | /api/evaluate/test-anthropic |
smoke check ANTHROPIC_API_KEY |
| POST | /api/deep |
body { company, role?, run? } → deep-research prompt or live grounded markdown |
| POST | /api/mode/:slug |
generic mode runner; allowlist: batch, contacto, followup, interview-prep, patterns, project, training |
| POST | /api/apply-helper |
body { url, jd? } → application checklist |
| GET | /api/scan-results |
{ en: {when, fresh[], filtered[], errors[]}, ru: { ... } } — last scan |
| GET | /api/scan/regional/config |
effective regional-scanner config (queries, negatives, sources). |
When run: true is set on /api/deep or /api/mode/:slug, the server prefers Anthropic (when both keys present), inlines cv.md + config/profile.yml + modes/_shared.md + the relevant mode template into a <project_context> block, and returns the model's grounded markdown directly. Soft cap: 200 KB on the assembled prompt — overflow returns 413.
npm test # 1000 unit/integration tests
npm run test:e2e # 20 smoke e2e (boots own server)
npm run test:e2e:full # 23 comprehensive e2e
npm run test:e2e:browser # 70 Playwright browser (smoke + full-cycle + forms + locale-sweep)
npm run test:coverage # same as `npm test` plus V8 coverage| Suite | Tests | What |
|---|---|---|
node --test tests/*.test.mjs (unit + integration) |
1000 | Every endpoint, ephemeral server, no network. 110 files: parsers, scanners (mocked), runners, anthropic/openai, security headers, XSS, JD sanitize, URL validation, i18n parity, + the v1.55→v1.56 UX-fix suites. |
tests/e2e.mjs (smoke) |
20 | Playwright headless: every route renders, basic flows. |
tests/e2e-comprehensive.mjs |
23 | Full Playwright walkthrough: 11 routes + 12 functional flows. |
npm run test:e2e:browser (playwright-smoke + playwright-full-cycle + playwright-forms + playwright-locale-sweep) |
70 | Browser-driven: dashboard render, navigation, language switch, 404, health, tracker round-trip, pipeline add + invalid-URL sweep, reports, evaluate manual fallback, config keys masked, CV PUT XSS strip, pipeline preview 400, auto-pipeline SSE. |
| Total | 1113 | 0 fails, 0 flakes |
Coverage: ~95.7% line / ~87% branch via --experimental-test-coverage.
Parsers are pure functions (no I/O) — tested against real data fragments from applications.md, pipeline.md, and reports/*.md. API tests boot the Express app on an ephemeral port and exercise every endpoint end-to-end. Scanner tests mock fetch so they pass even if hh.ru blocks your IP. The Playwright browser smoke runs against the in-process server and resolves Playwright via the parent project's node_modules — no new dependency in web-ui/.
CI runs the unit + e2e + Playwright matrix on every push to main against Node 18 / 20 / 22.
Environment variables (read at server start, all optional except where noted):
| Var | Default | Purpose |
|---|---|---|
PORT |
4317 |
Express bind port |
HOST |
127.0.0.1 |
Express bind host. CSP attaches when non-loopback; auth gate planned for v2.0.0. |
CAREER_OPS_ROOT |
.. from script |
Where to find cv.md, data/, portals.yml, modes/, etc. |
ANTHROPIC_API_KEY |
unset | Enables /api/evaluate, /api/deep, /api/mode/:slug live mode (preferred when both keys set). |
ANTHROPIC_MODEL |
claude-sonnet-4-6 |
Override Anthropic model. |
GEMINI_API_KEY |
unset | Forwarded to gemini-eval.mjs and used as fallback for /api/evaluate. |
GEMINI_MODEL |
gemini-2.0-flash |
Override Gemini model. |
OPENAI_API_KEY |
unset | Headless live-eval (3rd in the auto order) + parent Codex/OpenAI CLI flow. |
OPENAI_MODEL |
gpt-5-codex |
Override OpenAI model. |
QWEN_API_KEY |
unset | Headless live-eval via DashScope OpenAI-compatible (4th in the auto order). |
QWEN_MODEL |
qwen-max |
Override Qwen model. |
OPENROUTER_API_KEY |
unset | Headless live-eval via OpenRouter — one key, 300+ models (5th / last in auto). |
OPENROUTER_MODEL |
openrouter/auto |
vendor/model id. Catalogue loaded live from GET /api/openrouter/models. |
portals.yml extension recognized by this UI (add to your existing file in the parent project):
russian_portals:
sources: ["hh", "habr"]
area: 113 # hh.ru area id
per_page: 50
only_remote: false
queries: ["Senior PHP", "Тимлид Go", ...]You can also extend any company entry with an explicit api: URL. See docs/portals-examples.md (in this repo) for ready-to-paste blocks for 24 verified companies.
- Server binds to
127.0.0.1by default — never exposed to the internet without explicitHOST=0.0.0.0. - Path sanitization (v1.21.0): every
:name/:slugroute param goes throughsanitizePathName()inserver/lib/security.mjs— strips non-[\w-.], drops leading dot-runs, collapses internal dot-runs, caps at 200 chars, empty → 400. Replaces 10 duplicated regex copies that previously kept..pdf/....mdthrough. - DNS-rebind defense (v1.21.0):
/api/pipeline/previewand/api/auto-pipelineroute throughserver/lib/safe-fetch.mjs::safeGet— one DNS lookup, pinned TCP connection, SNI/Host targeted at the original hostname. No second lookup, no TOCTOU window. - Concurrent-write mutex (v1.21.0):
tracker.mjs,pipeline.mjs(POST + DELETE), andauto-pipeline.mjs's tracker step wrap read-modify-write inwithFileLock(path, fn)fromserver/lib/file-lock.mjs. Concurrent POSTs no longer drop rows. - LLM rate-limit (v1.21.0):
/api/evaluate,/api/deep,/api/mode/:slug,/api/auto-pipelinewearllmRateLimitfromserver/lib/rate-limit.mjs. No-op on loopback; 10 req/min/IP onHOST=0.0.0.0. Configurable viaLLM_RATE_LIMIT="N/Ws". 429 +Retry-After. - CV XSS strip (v1.22.0 hardening):
stripDangerousMarkdownis now entity-aware — decodes<,>,&#NN;,&#xHH;before regex strip so<script>andjavascript:payloads can't bypass. - Subprocess invocations use
spawnwith arg arrays — no shell interpolation, ever.bashrunner uses--noprofile --norcto ignore~/.bashrc. - Streaming endpoints kill the child process on client disconnect (no orphaned scanners).
- Write endpoints touch only known career-ops paths:
data/,jds/,cv.md,config/,portals.yml,output/,reports/,interview-prep/,modes/_profile.md. Never anywhere else. - The connection banner pings
/api/healthwith exponential backoff (3 s → 6 s → 12 s → 24 s → 60 s) while disconnected and auto-clears on recovery (v1.22.0 M-6).
The fully LLM-driven modes (oferta, deep, contacto, apply, batch, patterns, followup) need an LLM to actually run. The web UI resolves a provider from the auto order Anthropic → Gemini → OpenAI → Qwen → OpenRouter (or whatever LLM_PROVIDER pins):
- Anthropic (preferred) — set
ANTHROPIC_API_KEYin the parent project's.env. Routes throughrunAnthropicwithcv.md/config/profile.yml/modes/_shared.md/ mode template inlined automatically (REVIEW-A1). Verified live in v1.8.0+ withclaude-sonnet-4-6returning 26 KB of grounded markdown for a deep-research call. gemini-eval.mjsas fallback — works out of the box when onlyGEMINI_API_KEYis set.- OpenAI / Qwen / OpenRouter — zero-dep OpenAI-compatible clients (the
_tailProvider()path). OpenRouter (v1.57.0) is the most flexible: oneOPENROUTER_API_KEYfronts 300+ models from every major lab, and the#/configmodel dropdown is populated live fromGET /api/openrouter/models(server-side proxy, CSP-safe, curated offline fallback). - Copy-paste prompt — when no key is set, the UI generates a ready prompt formatted for Claude Code / ChatGPT / Gemini Web.
The existing /career-ops apply Playwright form-fill flow inside Claude Code remains the only way to truly auto-fill application forms — the UI's Apply helper generates a checklist instead.
For the production-readiness assessment (deployment gates, risk register, deferred work), see docs/PRODUCTION-READINESS.md. TL;DR: ready for single-tenant loopback; LAN exposure waits on the v2.0 P-12 auth gate.
The UI ships 9 locales — en, es, fr, pt-BR, ko, ja, ru, zh-CN, zh-TW. Since v1.60.0 (I18N-SPLIT) translations live one file per locale under public/js/lib/locales/ — i18n-dict.<lang>.js, each a flat key → string table — plus a shared i18n-dict.aliases.js. i18n-dict.js assembles them into window.__I18N_DICT; i18n.js resolves t('key', 'fallback'). No build step, no runtime fetch — a translator edits a single language file in isolation.
Add or change a string:
// public/js/lib/locales/i18n-dict.en.js → 'scan.newButton': 'Run scan',
// public/js/lib/locales/i18n-dict.es.js → 'scan.newButton': 'Ejecutar búsqueda',
// …add the same key to all 9 locale files (parity is gated)Then use it via data-i18n="scan.newButton" in markup or t('scan.newButton') in JS, and run npm test. To add a brand-new language, register it in i18n.js (LANGS + detect()), the assembler, index.html, and the locale-enumerating tooling.
📖 Full guide: docs/LOCALIZATION.md — the per-locale layout, the @alias mechanism, adding a new locale step-by-step, and every i18n CI gate.
Issues and PRs welcome. House rules:
- Run
npm testbefore pushing — 284 checks green is the bar (plus 12 Playwright if you touch UI). - Non-trivial changes go through the GSD pipeline. See
docs/sdd/SDD-GUIDE.md. - Don't modify anything in the parent
career-ops/project from inside this repo. The whole point is that this is a non-invasive overlay. Hard rules inCLAUDE.md. - Conventional commits:
feat,fix,refactor,docs,test,chore,perf,ci. Optional scope:feat(scan):. Breaking change:feat!:. - Tests must be CI-isolated — bootstrap fixtures via
mkdtempSyncorCAREER_OPS_ROOT=$(mktemp -d).
Driving the repo from a non-Claude CLI (Codex, Aider, Cursor, Gemini)? Read AGENTS.md or GEMINI.md — both shim to the canonical CLAUDE.md.
After the one-command install you have two empty git clones, scaffolded with
starter cv.md, config/profile.yml, portals.yml, data/applications.md,
and data/pipeline.md files containing EDIT ME markers. The Health page
should already be all-green on first launch. Replace the placeholders with
your real data:
You have three options:
- Option A — paste an existing resume: open
career-ops/cv.md, replace the EDIT-ME placeholders with your real resume in clean markdown (sections: Summary, Experience, Projects, Education, Skills). The simpler the better —career-opsreads it as plain text. - Option B — upload from the UI: click CV in the sidebar →
📁 Upload CV → pick your
.md/.txtfile → review the preview → click 💾 Save. - Option C — give your LinkedIn URL to Claude Code: open Claude Code in
career-ops/, run/career-ops, paste your LinkedIn URL, and ask "extract my CV from this and write it to cv.md".
Make every metric specific (e.g. "reduced p99 latency by 38%" not "improved performance"). The evaluation pipeline reads metrics straight from this file.
$EDITOR career-ops/config/profile.ymlReplace the placeholders for full name, email, location, LinkedIn, target roles, archetypes, salary target. The archetypes are the most important field — they're how every JD is matched against you.
$EDITOR career-ops/portals.ymlSet title_filter.positive (e.g. "PHP", "Go", "Backend", "Senior")
and title_filter.negative (e.g. "Junior", "Java", "iOS") to your
stack and seniority. The bundled tracked_companies list already includes
3 verified Greenhouse / Ashby boards (GitLab, Vercel, Linear). For 24+ more
ready-to-paste blocks, see docs/portals-examples.md.
If you want hh.ru / Habr Career scanning, edit the russian_portals: block
that the setup script created — add your search queries (e.g. "Senior PHP",
"Тимлид Go").
The UI prefers Anthropic over Gemini when both are present. Either or neither works — without a key, Evaluate returns a copy-paste prompt for Claude Code instead.
# Anthropic (preferred)
echo "ANTHROPIC_API_KEY=sk-ant-..." >> career-ops/.env
# Gemini (fallback)
echo "GEMINI_API_KEY=AIza..." >> career-ops/.envOr set them via the App settings page in the UI (/#/config) — same
file, masked-on-read, applied to process.env immediately.
Refresh the Health page — every required check should be green. Then:
- Click 🌐 Scan → wait ~5 seconds → Greenhouse / Ashby / Lever / Workable / SmartRecruiters / Workday + hh.ru / Habr Career are scanned, vacancies appear in the table below.
- Click any title → the original posting opens in a new tab.
- Filter by stack chips (PHP / Go / Backend / Senior) until you see something promising.
- Copy the URL → paste it into Pipeline → click Evaluate to score it 0-5 live (Anthropic / Gemini) or get a manual prompt.
- Reports land in
reports/, tracker indata/applications.md, live deep-research ininterview-prep/. All visible in the UI.
Translations of this guide live in each language-specific README: Español · Français · Português (Brasil) · 한국어 · 日本語 · Русский · 简体中文 · 繁體中文
MIT. See LICENSE.
Built on top of career-ops by santifer. Thanks for the brilliant pipeline.
