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.69.2
fix(test):A test (npm test
no longer overwrites your realconfig/profile.yml
/data/scan-history.tsv
.critical-fixes.test.mjs
) importedprompts.mjs
(โpaths.mjs
) at the top of the file, soPROJECT_ROOT
resolved to therealparent before the test setCAREER_OPS_ROOT
to a temp dir โ andPUT /api/profile
leaked an "Acceptance Test" fixture into your profile on every run. Now the carrier is loaded via dynamicimport()
after the env is set, andtests/test-root-isolation.test.mjs
guards the whole suite against the pattern. No production-code change.
Full suite1086/1086green ยท 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 dashboardIt runson top of[.]santifer/career-ops
insidea career-ops project ascareer-ops/web-ui/
and reads yourcv.md
,config/
,data/
from the parent folder via../
. It doesnotwork standalone โ you need the parentcareer-ops
repo 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 | bash
Clones 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/.env
The 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 verb
Prefix 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-โฆ --yes
Or 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โ not from a standalone
career-ops/web-ui/
career-ops-ui/
clone. - The
parent and containscareer-ops/
folder existscv.md
andconfig/
. 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 / Workdayand 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.Editcv.md
with 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.md
shims 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-ops
Follow career-ops onboarding so cv.md
, config/profile.yml
, and portals.yml
exist.
git clone https://github.com/Fighter90/career-ops-ui.git web-ui
Your 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.sh
The 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 LAN
If 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.sh
career-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 start
Open 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 modalor 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 inportals.yml::tracked_companies
with 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, โฆ). Addprovider: rss
- the feed URL to
portals.yml
โ no code changes required.hh.ruโ HTML scrape ofhh.ru/search/vacancy
. Works from any IP, no key, no proxy. (The JSON APIapi.hh.ru
is 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 ofcareer.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: true
The 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
โ โโ 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 |
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.1
by default โ never exposed to the internet without explicitHOST=0.0.0.0
. Path sanitization (v1.21.0): every:name
/:slug
route 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
/....md
through.DNS-rebind defense (v1.21.0):/api/pipeline/preview
and/api/auto-pipeline
route 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-pipeline
wearllmRateLimit
fromserver/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):stripDangerousMarkdown
is now entity-aware โ decodes<
,>
,&#NN;
,&#xHH;
before regex strip so<script>
andjavascript:
payloads can't bypass.- Subprocess invocations use
spawn
with arg arrays โno shell interpolation, ever.bash
runner uses--noprofile --norc
to 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/health
with 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)โ setANTHROPIC_API_KEY
in the parent project's.env
. Routes throughrunAnthropic
withcv.md
/config/profile.yml
/modes/_shared.md
/ mode template inlined automatically (REVIEW-A1). Verified live in v1.8.0+ withclaude-sonnet-4-6
returning 26 KB of grounded markdown for a deep-research call.as fallback โ works out of the box when onlygemini-eval.mjs
GEMINI_API_KEY
is set.OpenAI / Qwen / OpenRouterโ zero-dep OpenAI-compatible clients (the_tailProvider()
path).OpenRouter(v1.57.0) is the most flexible: oneOPENROUTER_API_KEY
fronts 300+ models from every major lab, and the#/config
model 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
window.__I18N_DICT
; resolves
i18n.js
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 test
before 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 in.CLAUDE.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
mkdtempSync
orCAREER_OPS_ROOT=$(mktemp -d)
.
Driving the repo from a non-Claude CLI (Codex, Aider, Cursor, Gemini)? Read AGENTS.md or
โ both shim to the canonical
GEMINI.md
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: opencareer-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-ops
reads it as plain text.Option B โ upload from the UI: clickCV in the sidebar โ๐ Upload CVโ pick your.md
/.txt
file โ review the preview โ click๐พ Save.** Option C โ give your LinkedIn URL to Claude Code:**open Claude Code incareer-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.yml
Replace 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.yml
Set 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.
echo "ANTHROPIC_API_KEY=sk-ant-..." >> career-ops/.env
echo "GEMINI_API_KEY=AIza..." >> career-ops/.env
Or 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.
Thanks to everyone who helps build career-ops-ui. The project is maintained by Fighter90 and improved by community contributions โ see the full list on the contributors graph.