{"slug": "show-hn-a-local-first-job-search-command-center-no-cloud-no-telemetry", "title": "Show HN: A local-first job-search command center (no cloud, no telemetry)", "summary": "Fighter90 released career-ops-ui v1.69.2, a local-first web dashboard for the open-source AI job-search pipeline career-ops. The tool runs entirely on the user's machine with no cloud accounts or telemetry, providing a CRM-style interface to search, evaluate, and track job applications using a six-dimension scoring rubric against the user's CV.", "body_md": "A clean, docs-style web interface for the\n\n[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.\n\n**English** | [Español](/Fighter90/career-ops-ui/blob/main/README.es.md) | [Português (Brasil)](/Fighter90/career-ops-ui/blob/main/README.pt-BR.md) | [한국어](/Fighter90/career-ops-ui/blob/main/README.ko-KR.md) | [日本語](/Fighter90/career-ops-ui/blob/main/README.ja.md) | [Русский](/Fighter90/career-ops-ui/blob/main/README.ru.md) | [简体中文](/Fighter90/career-ops-ui/blob/main/README.zh-CN.md) | [繁體中文](/Fighter90/career-ops-ui/blob/main/README.zh-TW.md) | [Français](/Fighter90/career-ops-ui/blob/main/README.fr.md)\n\n🆕 Latest release — v1.69.2\n\nfix(test):A test (`npm test`\n\nno longer overwrites your real`config/profile.yml`\n\n/`data/scan-history.tsv`\n\n.`critical-fixes.test.mjs`\n\n) imported`prompts.mjs`\n\n(→`paths.mjs`\n\n) at the top of the file, so`PROJECT_ROOT`\n\nresolved to therealparent before the test set`CAREER_OPS_ROOT`\n\nto a temp dir — and`PUT /api/profile`\n\nleaked an \"Acceptance Test\" fixture into your profile on every run. Now the carrier is loaded via dynamic`import()`\n\nafter the env is set, and`tests/test-root-isolation.test.mjs`\n\nguards the whole suite against the pattern. No production-code change.\n\nFull suite1086/1086green · i18n + docs synced across all 9 locales.\n\n[career-ops](https://career-ops.org) 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.\n\n**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`\n\n/ `data/applications.md`\n\n/ `reports/`\n\nfiles. Both share the same data.\n\n**Action thresholds by score** (from [career-ops.org/docs](https://career-ops.org/docs)):\n\n| Score | Next step |\n|---|---|\n≥ 4.5 |\n`/career-ops apply` — high fit, push immediately |\n4.0 – 4.4 |\napply, or `/career-ops contacto` for warm intro |\n3.5 – 3.9 |\n`/career-ops deep` — research first |\n< 3.5 |\nskip unless you have a specific reason |\n\n**Canonical guides** at [career-ops.org/docs](https://career-ops.org/docs):\n\nImportant — career-ops-ui is a dashboardIt runson top of[.]`santifer/career-ops`\n\ninsidea career-ops project as`career-ops/web-ui/`\n\nand reads your`cv.md`\n\n,`config/`\n\n,`data/`\n\nfrom the parent folder via`../`\n\n. It doesnotwork standalone — you need the parent`career-ops`\n\nrepo too. Don't clone it on its own and run`init`\n\n; use one of the two options below.\n\n```\ncurl -fsSL https://raw.githubusercontent.com/Fighter90/career-ops-ui/main/bin/setup.sh | bash\n```\n\nClones **both** repos, arranges the `career-ops/web-ui/`\n\nlayout, installs deps, runs the doctor, and starts the server at [http://127.0.0.1:4317](http://127.0.0.1:4317) — then opens the dashboard.\n\nIf you already have career-ops configured and just want the dashboard, clone the UI **inside** it as `web-ui`\n\n:\n\n```\ncd career-ops                                                   # ← your existing career-ops project\ngit clone https://github.com/Fighter90/career-ops-ui.git web-ui\ncd web-ui\nnpm install\nnpx career-ops-ui init        # interactive: pick LLM provider + paste its key → parent career-ops/.env\n```\n\nThe nested `web-ui/`\n\nlayout is exactly what lets the UI resolve your `../cv.md`\n\n, `../config/`\n\n, `../data/`\n\n. Run `npm link`\n\n**once** if you'd rather type the bare `career-ops-ui <verb>`\n\ninstead of `npx career-ops-ui <verb>`\n\n.\n\n```\ncareer-ops-ui setup    # bootstrap: install deps → doctor → run (SKIP_START=1 to stop before run)\ncareer-ops-ui init     # pick LLM provider + paste its key (interactive)\ncareer-ops-ui doctor   # verify Node / project / keys / Playwright (exit 0 ⇔ all required green)\ncareer-ops-ui run      # launch the server at http://127.0.0.1:4317\ncareer-ops-ui open     # open + RAISE the dashboard tab in your browser\ncareer-ops-ui help     # list every verb\n```\n\nPrefix with `npx `\n\n(e.g. `npx career-ops-ui run`\n\n) if you didn't `npm link`\n\n. After `setup`\n\n/`run`\n\nthe tab opens **and is brought to the front** automatically; set `NO_OPEN=1`\n\nto disable auto-open (headless / CI).\n\n`init`\n\nis the provider wizard — choose **Claude / Claude Code** (`ANTHROPIC_API_KEY`\n\n), **Gemini / Gemini CLI** (`GEMINI_API_KEY`\n\n), **Codex / OpenCode CLI** (`OPENAI_API_KEY`\n\n), or **Auto** (Anthropic → Gemini fallback). Keys are typed with echo suppressed and written to the parent `career-ops/.env`\n\nvia the same validated path as the `#/config`\n\nAPI-keys tab. Non-interactive form for CI:\n\n```\ncareer-ops-ui init --provider claude --anthropic-key sk-ant-… --yes\ncareer-ops-ui init --provider gemini --gemini-key …          --yes\ncareer-ops-ui init --provider auto   --openai-key sk-…       --yes\n```\n\nOr set it by hand: `echo \"ANTHROPIC_API_KEY=sk-ant-…\" >> career-ops/.env`\n\n. The provider sets `LLM_PROVIDER`\n\n(`auto`\n\n| `claude`\n\n| `gemini`\n\n); change it any time from ** #/config → API keys** without restarting.\n\nIf `career-ops-ui init`\n\nfails or the command isn't found (common right after a `git pull`\n\n):\n\n```\ncd career-ops/web-ui\nnpm install\nnpx career-ops-ui init        # npx runs the local bin even without `npm link`\n```\n\nMake sure:\n\n- You're running it from\n**inside**— not from a standalone`career-ops/web-ui/`\n\n`career-ops-ui/`\n\nclone. - The\n**parent** and contains`career-ops/`\n\nfolder exists`cv.md`\n\nand`config/`\n\n. If you cloned career-ops-ui on its own, move it (or re-clone) so it sits at`career-ops/web-ui/`\n\n— or just run Option 1's curl, which arranges the layout for you. `career-ops-ui doctor`\n\n(or`npx career-ops-ui doctor`\n\n) prints exactly what's missing.\n\n[career-ops](https://github.com/santifer/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`\n\n, `data/applications.md`\n\n, `reports/*.md`\n\n, `data/pipeline.md`\n\n, `portals.yml`\n\n, `config/profile.yml`\n\n— easy to lose, hard to skim.\n\n`career-ops-ui`\n\nputs a polished UI on top:\n\n**Auto-pipeline**— paste one job URL on`#/auto`\n\n, 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.md`\n\nwith 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`\n\n/`AGENTS.md`\n\n/`GEMINI.md`\n\nshims point to a single source of truth.\n\nIt's pure additions: nothing inside `career-ops/`\n\nchanges. All your customizations stay yours.\n\n```\ngit clone https://github.com/santifer/career-ops.git\ncd career-ops\n```\n\nFollow [career-ops onboarding](https://github.com/santifer/career-ops#first-run--onboarding) so `cv.md`\n\n, `config/profile.yml`\n\n, and `portals.yml`\n\nexist.\n\n```\ngit clone https://github.com/Fighter90/career-ops-ui.git web-ui\n```\n\nYour tree now looks like:\n\n```\ncareer-ops/\n├─ cv.md\n├─ portals.yml\n├─ config/\n├─ data/\n├─ modes/\n├─ reports/\n├─ scan.mjs … doctor.mjs … (etc)\n└─ web-ui/                 ← this repo\n   ├─ bin/start.sh\n   ├─ package.json\n   ├─ server/\n   ├─ public/\n   └─ tests/\nbash web-ui/bin/start.sh\n```\n\nThe script:\n\n- Checks Node ≥ 18.\n`npm install`\n\n(only on first run, three deps — Express + js-yaml + multer).- Starts the Express server on\n`127.0.0.1:4317`\n\n. - Opens\n[http://127.0.0.1:4317/](http://127.0.0.1:4317/)in your default browser.\n\nCustom port / host:\n\n```\nPORT=8080 bash web-ui/bin/start.sh\nHOST=0.0.0.0 PORT=4317 bash web-ui/bin/start.sh   # expose on LAN\n```\n\nIf you cloned the repo somewhere else (not as `career-ops/web-ui`\n\n), point at career-ops via env:\n\n```\nCAREER_OPS_ROOT=/path/to/career-ops bash bin/start.sh\n```\n\n`career-ops/data/pipeline.md`\n\nships with two QA fixture URLs (`example.com/qa-fixture-*`\n\n) so the test suite can run hermetically. On a fresh clone you'll see Pipeline showing `2 pending`\n\n— those are not real jobs. Purge them before your first scan:\n\n```\nmake clean-test-fixtures        # removes pipeline.md fixture rows + qa-fixture-* applications.md rows\nnpm start\n```\n\nOpen [http://127.0.0.1:4317](http://127.0.0.1:4317). Pipeline counter should now read `0 pending`\n\n. The Makefile is idempotent — re-running it on a clean tree is a no-op.\n\nNode.js |\n≥ 18 (uses native `fetch` , `node:test` ) |\ncareer-ops |\nCloned and onboarded — see above |\nOptional |\n`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. |\nOptional |\nRun from a Russian IP / VPN if hh.ru returns 403. Habr Career works from any IP regardless. |\nOptional |\nPlaywright (already a transitive dep of career-ops) for the e2e test suite. |\n\n| Page | What it does |\n|---|---|\nDashboard |\nAggregated counts (apps / pipeline / reports), avg score, status breakdown, latest 5 apps + latest report. |\nScan |\n🌐 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. |\nPipeline |\nCRUD 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. |\nEvaluate |\nPaste 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. |\nDeep research |\nSame fallback chain as Evaluate. Live Anthropic returns ~10-30 KB of grounded markdown saved to `interview-prep/<company>-<role>.md` . |\nModes |\n7 generic mode pages (`/#/project` , `/#/training` , `/#/followup` , `/#/batch` , `/#/contacto` , `/#/interview-prep` , `/#/patterns` ) with the same Anthropic / Gemini / manual fallback. |\nApply helper |\nGenerates a submission checklist; the actual Playwright form-fill stays in `/career-ops apply` inside Claude Code. |\nTracker |\nFilterable 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. |\nReports |\nBrowse and read every report under `reports/` with parsed header (Score / Legitimacy / URL). |\nCV |\nLive 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). |\nProfile |\nRead-only view of `config/profile.yml` + archetypes — UI-friendly summary. |\nApp settings |\nIn-UI editor for parent `.env` keys: `ANTHROPIC_API_KEY` , `GEMINI_API_KEY` , model overrides, port / host. Secrets masked on read. |\nHealth |\nAll setup checks in OK / OPTIONAL / FAIL badges + buttons to run `doctor.mjs` and `verify-pipeline.mjs` . |\nHelp |\nIn-app Markdown user guide (`/#/help` ), localized for all 9 supported languages (en / es / fr / pt-BR / ko-KR / ja / ru / zh-CN / zh-TW). |\nActivity log |\nAudit trail of every state-changing request (writes, runs, scans). Secrets redacted. |\nNotifications 🔔 (v1.58.34 / v1.58.35) |\nTop-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. |\n\nGlobal keyboard shortcuts:\n\n`Ctrl+K`\n\n/`Cmd+K`\n\n— 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.\n`Esc`\n\n— close any open modal**or** the notifications drawer (v1.58.34).\n\nZero-token portal scanning that actually returns vacancies. **One 🌐 Scan button** in the UI runs every configured source in a single sweep:\n\n**Greenhouse / Ashby / Lever / Workable / SmartRecruiters / Workday**— public boards-api for every company in`portals.yml::tracked_companies`\n\nwith 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`\n\n+ the feed URL to`portals.yml`\n\n— no code changes required.**hh.ru**— HTML scrape of`hh.ru/search/vacancy`\n\n. Works from any IP, no key, no proxy. (The JSON API`api.hh.ru`\n\nis 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`\n\n. Works from any IP, no auth.\n\nPoint the scanner at any RSS-based job board by adding an entry with `provider: rss`\n\nand an `rss:`\n\n(or `feed_url:`\n\n) key to `portals.yml`\n\n:\n\n```\ntracked_companies:\n  - name: LaraJobs\n    provider: rss\n    rss: https://larajobs.com/feed\n    enabled: true\n  - name: WeWorkRemotely\n    provider: rss\n    rss: https://weworkremotely.com/remote-jobs.rss\n    enabled: true\n```\n\nThe adapter parses `<item>`\n\nblocks using a tiny regex-based parser (no XML library needed). It extracts `title`\n\n, `link`\n\n(→ `url`\n\n), `pubDate`\n\n(→ `date`\n\n), and `description`\n\n(→ `snippet`\n\n, HTML stripped). Remote status is inferred from `/remote|anywhere/i`\n\nin the title or description; company name is pulled from `dc:creator`\n\n, 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.\n\nAll sources go through the same pipeline: normalize → filter (`title_filter.positive`\n\n/ `title_filter.negative`\n\n) → dedup against `data/scan-history.tsv`\n\n+ `data/pipeline.md`\n\n+ `data/applications.md`\n\n→ append to `data/pipeline.md`\n\n→ save full result set to `data/last-scan.json`\n\nfor the UI's filterable table.\n\nConfigure via `portals.yml`\n\n:\n\n```\ntitle_filter:\n  positive: [backend, engineer, senior, tech lead, golang, php]\n  negative: [junior, intern, frontend, ios, android]\ntracked_companies:\n  - { name: Stripe, enabled: true, careers_url: https://job-boards.greenhouse.io/stripe }\n  - { name: Linear, enabled: true, careers_url: https://jobs.ashbyhq.com/linear }\n  # ...\nrussian_portals:\n  sources: [\"hh\", \"habr\"]   # one or both\n  area: 113                  # 1=Moscow, 2=SPb, 113=Russia, 1001=remote\n  per_page: 50\n  only_remote: false\n  queries: [\"Senior PHP\", \"Senior Go\", \"Tech Lead\"]\n```\n\nAll sources flow through a single SSE endpoint: `/api/stream/scan?source=ats|regional|both`\n\n. The **🌐 Scan** UI button calls `source=both`\n\nso every adapter (Greenhouse / Ashby / Lever / Workable / SmartRecruiters / Workday + hh.ru + Habr Career + Trudvsem + GetMatch + GeekJob) runs in one connection. Honors `AbortSignal`\n\non client disconnect — no orphan fetches.\n\n```\ncareer-ops-ui/\n├─ CLAUDE.md                 # project-level agent instructions (canonical)\n├─ AGENTS.md                 # Codex / Aider / generic CLI shim → CLAUDE.md\n├─ GEMINI.md                 # Gemini CLI shim → CLAUDE.md\n├─ .aiignore                 # exclusion list for AI tools\n├─ .claude/                  # Claude Code agent config\n│  ├─ agents/                # 3 project-specific subagents (route, view, test isolation)\n│  └─ commands/               # slash-command stubs\n├─ bin/start.sh              # one-shot launcher (Node check → npm install → server → open browser)\n├─ package.json              # 2 runtime deps: express, js-yaml\n├─ server/\n│  ├─ index.mjs              # ~130 LOC orchestrator: middleware + 12 register<Topic>Routes(app) calls + SPA catch-all\n│  └─ lib/\n│     ├─ paths.mjs           # absolute paths to career-ops files (CAREER_OPS_ROOT aware)\n│     ├─ parsers.mjs         # markdown / pipeline / report parsers (GFM-compliant pipe escapes)\n│     ├─ runner.mjs          # runNodeScript() + streamNodeScript() with SIGTERM→SIGKILL escalation + 30 min cap\n│     ├─ security.mjs        # isValidJobUrl, stripDangerousMarkdown, sanitizeJobDescription, isPubliclyExposed\n│     ├─ prompts.mjs         # bundleProjectContext, buildEvaluationPrompt, buildDeepPrompt, buildModePrompt\n│     ├─ store.mjs           # safeReadApps/Pipeline/Reports, checkProfileCustomized, ensureRussianPortalsDefaults\n│     ├─ anthropic.mjs       # minimal Anthropic SDK adapter (runAnthropic, hasAnthropicKey, hasGeminiKey)\n│     ├─ env-config.mjs      # .env round-trip with secret masking + validation\n│     ├─ activity-log.mjs    # JSONL audit trail middleware (secrets redacted)\n│     ├─ dotenv.mjs          # tiny dotenv loader\n│     ├─ en-scanner.mjs      # in-process Greenhouse/Ashby/Lever orchestrator (AbortSignal aware)\n│     ├─ ru-scanner.mjs      # in-process hh.ru + Habr orchestrator (AbortSignal aware)\n│     ├─ sources/\n│     │  ├─ greenhouse.mjs   # boards-api.greenhouse.io client\n│     │  ├─ ashby.mjs        # api.ashbyhq.com client\n│     │  ├─ lever.mjs        # api.lever.co client\n│     │  ├─ hh.mjs           # hh.ru/search/vacancy HTML scraper (paginated, UA-aware)\n│     │  └─ habr.mjs         # career.habr.com HTML parser (no cheerio, regex only)\n│     └─ routes/             # 12 route modules — one per topic (P-2)\n│        ├─ activity.mjs     # /api/activity\n│        ├─ config.mjs       # /api/config (parent .env round-trip)\n│        ├─ content.mjs      # /api/cv, /api/profile, /api/portals, /api/modes\n│        ├─ health.mjs       # /api/health, /api/dashboard\n│        ├─ help.mjs         # /api/help/:lang\n│        ├─ jds.mjs          # /api/jds CRUD\n│        ├─ llm.mjs          # /api/evaluate, /api/deep, /api/mode/:slug, /api/apply-helper, /api/interview-prep*\n│        ├─ pipeline.mjs     # /api/pipeline + SSRF-safe preview proxy\n│        ├─ reports.mjs      # /api/reports\n│        ├─ runners.mjs      # /api/run/* + /api/stream/{scan,liveness,pdf} + /api/output/pdfs\n│        ├─ scan.mjs         # /api/stream/scan-{ru,en} + /api/scan-results\n│        └─ tracker.mjs      # /api/tracker\n├─ public/                   # static SPA — no build step\n│  ├─ index.html\n│  ├─ css/app.css            # design tokens (docs-style palette)\n│  └─ js/\n│     ├─ api.js              # fetch wrapper + connection-banner state + UI helpers + safe markdown renderer\n│     ├─ router.js           # hash-based router with 404 fallback + alias support\n│     ├─ app.js              # boot + global keyboard handlers + mobile sidebar drawer\n│     ├─ lib/{i18n,skills}.js\n│     └─ views/              # one file per page (dashboard, scan, pipeline, evaluate, deep, apply, tracker, reports, cv, settings, health, config, help, activity, mode-page)\n├─ docs/                     # public reference: architecture, API, data-flows, SDD, conventions, reviews\n│  ├─ PROJECT.md             # what / why / for-whom\n│  ├─ ROADMAP.md             # current milestone + completed history\n│  ├─ PRODUCTION-READINESS.md # honest deployment-gate assessment\n│  ├─ sdd/{SDD-GUIDE,CONVENTIONS}.md\n│  ├─ architecture/{OVERVIEW,SERVER,FRONTEND,API,DATA-FLOWS}.md\n│  └─ reviews/REVIEW-*.md\n└─ tests/                    # 1000 unit + 70 Playwright + 23/23 e2e:full + 20 e2e:smoke (baseline @ v1.60.0)\n   ├─ parsers.test.mjs       # markdown / pipeline / report parsers (pure functions)\n   ├─ api.test.mjs           # every endpoint, ephemeral server, no network\n   ├─ {ru,en}-scanner.test.mjs   # mocked fetch\n   ├─ pipeline-preview.test.mjs   # per-hop redirect validation (REVIEW-B1)\n   ├─ anthropic.test.mjs     # SDK adapter + log-guard test (REVIEW-B4)\n   ├─ url-validation.test.mjs    # SSRF reject sweep (FIX-M3 + M6 + M7)\n   ├─ cv-xss.test.mjs        # stripDangerousMarkdown round-trip\n   ├─ jd-sanitize.test.mjs   # sanitizeJobDescription\n   ├─ help.test.mjs / help-ui.test.mjs    # i18n parity across all 8 locales\n   ├─ playwright-smoke.mjs   # 12 browser flows (CV save, tracker, pipeline, evaluate, config, etc.)\n   └─ e2e{,-comprehensive}.mjs   # full Playwright walkthrough\n```\n\nVanilla HTML/CSS/JS keeps the surface area tiny: one `npm install`\n\nof two deps and you're running. No Webpack, no Vite, no `node_modules`\n\nof doom. The whole UI is < 30 KB minified. If you want hot-reload during development, `npm run dev`\n\nuses Node's built-in `--watch`\n\n.\n\nNon-trivial changes go through the GSD pipeline (`gsd-*`\n\nskills from `superpowers@claude-plugins-official`\n\n):\n\n```\ndiscuss → spec → plan → execute → verify → review\n```\n\nPublic reference: [ docs/sdd/SDD-GUIDE.md](/Fighter90/career-ops-ui/blob/main/docs/sdd/SDD-GUIDE.md). All planning artifacts live under\n\n`.planning/`\n\n(gitignored). The `docs/`\n\ntree is the long-lived public contract.All endpoints under `/api/*`\n\n. JSON in / JSON out unless noted.\n\n| Method | Path | Response |\n|---|---|---|\n| GET | `/api/health` |\n`{ ok, warnings, version, parentVersion, checks: [{name, ok, required, value?}] }` |\n| GET | `/api/dashboard` |\n`{ counts, avgScore, byStatus, recent, pipeline, lastReport }` |\n| GET | `/api/status/providers` |\n`{ activeProvider, activeModel, keysConfigured }` — LLM readiness for the onboarding banner + ⚡ cost hint (v1.55.3); includes `openrouter` (v1.57.0) |\n| GET | `/api/openrouter/models` |\n`{ models:[{id,name,context_length}], fallback, cached }` — OpenRouter catalogue proxy for the `#/config` model dropdown (v1.57.0) |\n| GET | `/api/activity?limit&type` |\ntail of `data/activity.jsonl` audit trail |\n| GET | `/api/help/:lang` |\nlocalized in-app user guide (fallback: `en.md` ) |\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/config` |\nknown env keys with secrets masked |\n| POST | `/api/config` |\nvalidate + write parent `.env` ; applies to `process.env` in-place |\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/tracker` |\n`{ rows: [parsed applications.md] }` |\n| POST | `/api/tracker` |\nbody `{ company, role, score?, status?, url?, notes?, date? }` — dedup-aware (case-insensitive on company + role) |\n| GET | `/api/pipeline` |\n`{ urls: [...] }` |\n| POST | `/api/pipeline` |\nbody `{ url }` → adds to `data/pipeline.md` with dedup + `isValidJobUrl` |\n| GET | `/api/pipeline/preview?url=…` |\nserver-side fetch proxy (per-hop SSRF check, ≤3 redirects, 8 KB cap) |\n| DELETE | `/api/pipeline?url=…` |\nremoves a URL |\n| GET | `/api/reports` |\nparsed list of `reports/*.md` |\n| GET | `/api/reports/:slug` |\nfull markdown + parsed header |\n| GET | `/api/jds` |\nlist of saved JD files |\n| GET | `/api/jds/:name` |\ntext/plain — raw JD |\n| POST | `/api/jds` |\nbody `{ text, slug? }` → saves to `jds/` |\n| DELETE | `/api/jds/:name` |\nunlink (`.txt` suffix required) |\n| GET | `/api/cv` |\n`{ markdown }` |\n| PUT | `/api/cv` |\nbody `{ markdown }` → writes `cv.md` (XSS-stripped, ≤1 MB) |\n| GET | `/api/profile` |\n`{ profile: yaml-parsed, raw: text }` |\n| GET | `/api/portals` |\n`{ portals: yaml-parsed, raw: text }` |\n| GET | `/api/modes` |\nlist of mode files |\n| GET | `/api/modes/:name` |\ntext/plain — raw mode prompt |\n| GET | `/api/output/pdfs` |\nlist of generated PDFs |\n| GET | `/api/output/pdfs/:name` |\ndownload (`Content-Disposition: attachment` ) |\n| GET | `/api/interview-prep` |\nlist of saved deep-research files |\n| GET | `/api/interview-prep/:name` |\n`{ name, markdown }` |\n| DELETE | `/api/interview-prep/:name` |\nunlink (`.md` suffix required) |\n\n| Method | Path | Wraps |\n|---|---|---|\n| POST | `/api/run/doctor` |\n`node doctor.mjs` |\n| POST | `/api/run/verify` |\n`node verify-pipeline.mjs` |\n| POST | `/api/run/normalize` |\n`node normalize-statuses.mjs` |\n| POST | `/api/run/dedup` |\n`node dedup-tracker.mjs` |\n| POST | `/api/run/merge` |\n`node merge-tracker.mjs` |\n| POST | `/api/run/sync-check` |\n`node cv-sync-check.mjs` |\n\nAll buffered runs cap at 60 s; SIGTERM → SIGKILL escalation after a 5 s grace period.\n\n| Method | Path | Streams |\n|---|---|---|\n| GET | `/api/stream/scan` |\nlegacy `node scan.mjs` (subprocess) |\n| GET | `/api/stream/scan?source=ats|regional|both` |\nconsolidated in-process scanner SSE — query: `dryRun=1` , `company=…` (ATS only). |\n| GET | `/api/stream/liveness` |\n`node check-liveness.mjs` |\n| GET | `/api/stream/pdf` |\n`node generate-pdf.mjs` |\n\nSSE event types:\n\n```\nevent: start    data: { script, args?, writeFiles? }\nevent: log      data: { stream: \"stdout\"|\"stderr\", line: string }\nevent: done     data: { code, counts?, errors? }\nevent: error    data: { message }\n```\n\n| Method | Path | Purpose |\n|---|---|---|\n| POST | `/api/evaluate` |\nbody `{ jd, save? }` → JD evaluation (A–G sections per `oferta.md` ) |\n| POST | `/api/evaluate/test-gemini` |\nsmoke check `GEMINI_API_KEY` |\n| POST | `/api/evaluate/test-anthropic` |\nsmoke check `ANTHROPIC_API_KEY` |\n| POST | `/api/deep` |\nbody `{ company, role?, run? }` → deep-research prompt or live grounded markdown |\n| POST | `/api/mode/:slug` |\ngeneric mode runner; allowlist: `batch` , `contacto` , `followup` , `interview-prep` , `patterns` , `project` , `training` |\n| POST | `/api/apply-helper` |\nbody `{ url, jd? }` → application checklist |\n| GET | `/api/scan-results` |\n`{ en: {when, fresh[], filtered[], errors[]}, ru: { ... } }` — last scan |\n| GET | `/api/scan/regional/config` |\neffective regional-scanner config (queries, negatives, sources). |\n\nWhen `run: true`\n\nis set on `/api/deep`\n\nor `/api/mode/:slug`\n\n, the server prefers Anthropic (when both keys present), inlines `cv.md`\n\n+ `config/profile.yml`\n\n+ `modes/_shared.md`\n\n+ the relevant mode template into a `<project_context>`\n\nblock, and returns the model's grounded markdown directly. Soft cap: 200 KB on the assembled prompt — overflow returns 413.\n\n```\nnpm test                       # 1000 unit/integration tests\nnpm run test:e2e               # 20 smoke e2e (boots own server)\nnpm run test:e2e:full          # 23 comprehensive e2e\nnpm run test:e2e:browser       # 70 Playwright browser (smoke + full-cycle + forms + locale-sweep)\nnpm run test:coverage          # same as `npm test` plus V8 coverage\n```\n\n| Suite | Tests | What |\n|---|---|---|\n`node --test tests/*.test.mjs` (unit + integration) |\n1000 |\nEvery 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. |\n`tests/e2e.mjs` (smoke) |\n20 | Playwright headless: every route renders, basic flows. |\n`tests/e2e-comprehensive.mjs` |\n23 | Full Playwright walkthrough: 11 routes + 12 functional flows. |\n`npm run test:e2e:browser` (`playwright-smoke` + `playwright-full-cycle` + `playwright-forms` + `playwright-locale-sweep` ) |\n70 |\nBrowser-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. |\nTotal |\n1113 |\n0 fails, 0 flakes |\n\nCoverage: ~95.7% line / ~87% branch via `--experimental-test-coverage`\n\n.\n\nParsers are pure functions (no I/O) — tested against real data fragments from `applications.md`\n\n, `pipeline.md`\n\n, and `reports/*.md`\n\n. API tests boot the Express app on an ephemeral port and exercise every endpoint end-to-end. Scanner tests mock `fetch`\n\nso 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`\n\n— no new dependency in `web-ui/`\n\n.\n\nCI runs the unit + e2e + Playwright matrix on every push to `main`\n\nagainst Node 18 / 20 / 22.\n\nEnvironment variables (read at server start, all optional except where noted):\n\n| Var | Default | Purpose |\n|---|---|---|\n`PORT` |\n`4317` |\nExpress bind port |\n`HOST` |\n`127.0.0.1` |\nExpress bind host. CSP attaches when non-loopback; auth gate planned for v2.0.0. |\n`CAREER_OPS_ROOT` |\n`..` from script |\nWhere to find `cv.md` , `data/` , `portals.yml` , `modes/` , etc. |\n`ANTHROPIC_API_KEY` |\nunset | Enables `/api/evaluate` , `/api/deep` , `/api/mode/:slug` live mode (preferred when both keys set). |\n`ANTHROPIC_MODEL` |\n`claude-sonnet-4-6` |\nOverride Anthropic model. |\n`GEMINI_API_KEY` |\nunset | Forwarded to `gemini-eval.mjs` and used as fallback for `/api/evaluate` . |\n`GEMINI_MODEL` |\n`gemini-2.0-flash` |\nOverride Gemini model. |\n`OPENAI_API_KEY` |\nunset | Headless live-eval (3rd in the `auto` order) + parent Codex/OpenAI CLI flow. |\n`OPENAI_MODEL` |\n`gpt-5-codex` |\nOverride OpenAI model. |\n`QWEN_API_KEY` |\nunset | Headless live-eval via DashScope OpenAI-compatible (4th in the `auto` order). |\n`QWEN_MODEL` |\n`qwen-max` |\nOverride Qwen model. |\n`OPENROUTER_API_KEY` |\nunset | Headless live-eval via OpenRouter — one key, 300+ models (5th / last in `auto` ). |\n`OPENROUTER_MODEL` |\n`openrouter/auto` |\n`vendor/model` id. Catalogue loaded live from `GET /api/openrouter/models` . |\n\n`portals.yml`\n\nextension recognized by this UI (add to your existing file in the parent project):\n\n```\nrussian_portals:\n  sources: [\"hh\", \"habr\"]\n  area: 113          # hh.ru area id\n  per_page: 50\n  only_remote: false\n  queries: [\"Senior PHP\", \"Тимлид Go\", ...]\n```\n\nYou can also extend any company entry with an explicit `api:`\n\nURL. See [ docs/portals-examples.md](/Fighter90/career-ops-ui/blob/main/docs/portals-examples.md) (in this repo) for ready-to-paste blocks for 24 verified companies.\n\n- Server binds to\n`127.0.0.1`\n\nby default — never exposed to the internet without explicit`HOST=0.0.0.0`\n\n. **Path sanitization (v1.21.0)**: every`:name`\n\n/`:slug`\n\nroute param goes through`sanitizePathName()`\n\nin`server/lib/security.mjs`\n\n— strips non-`[\\w-.]`\n\n, drops leading dot-runs, collapses internal dot-runs, caps at 200 chars, empty → 400. Replaces 10 duplicated regex copies that previously kept`..pdf`\n\n/`....md`\n\nthrough.**DNS-rebind defense (v1.21.0)**:`/api/pipeline/preview`\n\nand`/api/auto-pipeline`\n\nroute through`server/lib/safe-fetch.mjs::safeGet`\n\n— 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`\n\n,`pipeline.mjs`\n\n(POST + DELETE), and`auto-pipeline.mjs`\n\n's tracker step wrap read-modify-write in`withFileLock(path, fn)`\n\nfrom`server/lib/file-lock.mjs`\n\n. Concurrent POSTs no longer drop rows.**LLM rate-limit (v1.21.0)**:`/api/evaluate`\n\n,`/api/deep`\n\n,`/api/mode/:slug`\n\n,`/api/auto-pipeline`\n\nwear`llmRateLimit`\n\nfrom`server/lib/rate-limit.mjs`\n\n.**No-op on loopback**; 10 req/min/IP on`HOST=0.0.0.0`\n\n. Configurable via`LLM_RATE_LIMIT=\"N/Ws\"`\n\n. 429 +`Retry-After`\n\n.**CV XSS strip (v1.22.0 hardening)**:`stripDangerousMarkdown`\n\nis now entity-aware — decodes`<`\n\n,`>`\n\n,`&#NN;`\n\n,`&#xHH;`\n\nbefore regex strip so`<script>`\n\nand`javascript:`\n\npayloads can't bypass.- Subprocess invocations use\n`spawn`\n\nwith arg arrays —**no shell interpolation, ever**.`bash`\n\nrunner uses`--noprofile --norc`\n\nto ignore`~/.bashrc`\n\n. - Streaming endpoints kill the child process on client disconnect (no orphaned scanners).\n- Write endpoints touch only known career-ops paths:\n`data/`\n\n,`jds/`\n\n,`cv.md`\n\n,`config/`\n\n,`portals.yml`\n\n,`output/`\n\n,`reports/`\n\n,`interview-prep/`\n\n,`modes/_profile.md`\n\n. Never anywhere else. - The connection banner pings\n`/api/health`\n\nwith exponential backoff (3 s → 6 s → 12 s → 24 s → 60 s) while disconnected and auto-clears on recovery (v1.22.0 M-6).\n\nThe fully LLM-driven modes (`oferta`\n\n, `deep`\n\n, `contacto`\n\n, `apply`\n\n, `batch`\n\n, `patterns`\n\n, `followup`\n\n) need an LLM to actually run. The web UI resolves a provider from the `auto`\n\norder **Anthropic → Gemini → OpenAI → Qwen → OpenRouter** (or whatever `LLM_PROVIDER`\n\npins):\n\n**Anthropic (preferred)**— set`ANTHROPIC_API_KEY`\n\nin the parent project's`.env`\n\n. Routes through`runAnthropic`\n\nwith`cv.md`\n\n/`config/profile.yml`\n\n/`modes/_shared.md`\n\n/ mode template inlined automatically (REVIEW-A1). Verified live in v1.8.0+ with`claude-sonnet-4-6`\n\nreturning 26 KB of grounded markdown for a deep-research call.as fallback — works out of the box when only`gemini-eval.mjs`\n\n`GEMINI_API_KEY`\n\nis set.**OpenAI / Qwen / OpenRouter**— zero-dep OpenAI-compatible clients (the`_tailProvider()`\n\npath).**OpenRouter**(v1.57.0) is the most flexible: one`OPENROUTER_API_KEY`\n\nfronts 300+ models from every major lab, and the`#/config`\n\nmodel dropdown is populated live from`GET /api/openrouter/models`\n\n(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.\n\nThe existing `/career-ops apply`\n\nPlaywright 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.\n\nFor the production-readiness assessment (deployment gates, risk register, deferred work), see [ docs/PRODUCTION-READINESS.md](/Fighter90/career-ops-ui/blob/main/docs/PRODUCTION-READINESS.md). TL;DR: ready for single-tenant loopback; LAN exposure waits on the v2.0 P-12 auth gate.\n\nThe UI ships **9 locales** — `en`\n\n, `es`\n\n, `fr`\n\n, `pt-BR`\n\n, `ko`\n\n, `ja`\n\n, `ru`\n\n, `zh-CN`\n\n, `zh-TW`\n\n. Since **v1.60.0 (I18N-SPLIT)** translations live **one file per locale** under [ public/js/lib/locales/](/Fighter90/career-ops-ui/blob/main/public/js/lib/locales) —\n\n`i18n-dict.<lang>.js`\n\n, each a flat `key → string`\n\ntable — plus a shared `i18n-dict.aliases.js`\n\n. [assembles them into](/Fighter90/career-ops-ui/blob/main/public/js/lib/i18n-dict.js)\n\n`i18n-dict.js`\n\n`window.__I18N_DICT`\n\n; [resolves](/Fighter90/career-ops-ui/blob/main/public/js/lib/i18n.js)\n\n`i18n.js`\n\n`t('key', 'fallback')`\n\n. No build step, no runtime fetch — a translator edits a single language file in isolation.**Add or change a string:**\n\n```\n// public/js/lib/locales/i18n-dict.en.js   →   'scan.newButton': 'Run scan',\n// public/js/lib/locales/i18n-dict.es.js   →   'scan.newButton': 'Ejecutar búsqueda',\n// …add the same key to all 9 locale files (parity is gated)\n```\n\nThen use it via `data-i18n=\"scan.newButton\"`\n\nin markup or `t('scan.newButton')`\n\nin JS, and run `npm test`\n\n. To add a brand-new language, register it in `i18n.js`\n\n(`LANGS`\n\n+ `detect()`\n\n), the assembler, `index.html`\n\n, and the locale-enumerating tooling.\n\n📖 **Full guide:** [ docs/LOCALIZATION.md](/Fighter90/career-ops-ui/blob/main/docs/LOCALIZATION.md) — the per-locale layout, the\n\n`@alias`\n\nmechanism, adding a new locale step-by-step, and every i18n CI gate.Issues and PRs welcome. House rules:\n\n- Run\n`npm test`\n\nbefore pushing —**284 checks green** is the bar (plus 12 Playwright if you touch UI). - Non-trivial changes go through the GSD pipeline. See\n.`docs/sdd/SDD-GUIDE.md`\n\n- Don't modify anything in the parent\n`career-ops/`\n\nproject from inside this repo. The whole point is that this is a non-invasive overlay. Hard rules in.`CLAUDE.md`\n\n- Conventional commits:\n`feat`\n\n,`fix`\n\n,`refactor`\n\n,`docs`\n\n,`test`\n\n,`chore`\n\n,`perf`\n\n,`ci`\n\n. Optional scope:`feat(scan):`\n\n. Breaking change:`feat!:`\n\n. - Tests must be CI-isolated — bootstrap fixtures via\n`mkdtempSync`\n\nor`CAREER_OPS_ROOT=$(mktemp -d)`\n\n.\n\nDriving the repo from a non-Claude CLI (Codex, Aider, Cursor, Gemini)? Read [ AGENTS.md](/Fighter90/career-ops-ui/blob/main/AGENTS.md) or\n\n[— both shim to the canonical](/Fighter90/career-ops-ui/blob/main/GEMINI.md)\n\n`GEMINI.md`\n\n`CLAUDE.md`\n\n.After the one-command install you have two empty git clones, scaffolded with\nstarter `cv.md`\n\n, `config/profile.yml`\n\n, `portals.yml`\n\n, `data/applications.md`\n\n,\nand `data/pipeline.md`\n\nfiles containing **EDIT ME** markers. The Health page\nshould already be all-green on first launch. Replace the placeholders with\nyour real data:\n\nYou have three options:\n\n**Option A — paste an existing resume:** open`career-ops/cv.md`\n\n, replace the EDIT-ME placeholders with your real resume in clean markdown (sections: Summary, Experience, Projects, Education, Skills). The simpler the better —`career-ops`\n\nreads it as plain text.**Option B — upload from the UI:** click**CV** in the sidebar →**📁 Upload CV**→ pick your`.md`\n\n/`.txt`\n\nfile → review the preview → click**💾 Save**.** Option C — give your LinkedIn URL to Claude Code:**open Claude Code in`career-ops/`\n\n, run`/career-ops`\n\n, paste your LinkedIn URL, and ask*\"extract my CV from this and write it to cv.md\"*.\n\nMake every metric specific (e.g. *\"reduced p99 latency by 38%\"* not\n*\"improved performance\"*). The evaluation pipeline reads metrics straight\nfrom this file.\n\n```\n$EDITOR career-ops/config/profile.yml\n```\n\nReplace the placeholders for full name, email, location, LinkedIn, target\nroles, archetypes, salary target. The **archetypes** are the most important\nfield — they're how every JD is matched against you.\n\n```\n$EDITOR career-ops/portals.yml\n```\n\nSet `title_filter.positive`\n\n(e.g. `\"PHP\"`\n\n, `\"Go\"`\n\n, `\"Backend\"`\n\n, `\"Senior\"`\n\n)\nand `title_filter.negative`\n\n(e.g. `\"Junior\"`\n\n, `\"Java\"`\n\n, `\"iOS\"`\n\n) to your\nstack and seniority. The bundled `tracked_companies`\n\nlist already includes\n3 verified Greenhouse / Ashby boards (GitLab, Vercel, Linear). For 24+ more\nready-to-paste blocks, see [ docs/portals-examples.md](/Fighter90/career-ops-ui/blob/main/docs/portals-examples.md).\n\nIf you want hh.ru / Habr Career scanning, edit the `russian_portals:`\n\nblock\nthat the setup script created — add your search queries (e.g. `\"Senior PHP\"`\n\n,\n`\"Тимлид Go\"`\n\n).\n\nThe UI prefers Anthropic over Gemini when both are present. Either or\nneither works — without a key, **Evaluate** returns a copy-paste prompt\nfor Claude Code instead.\n\n```\n# Anthropic (preferred)\necho \"ANTHROPIC_API_KEY=sk-ant-...\" >> career-ops/.env\n# Gemini (fallback)\necho \"GEMINI_API_KEY=AIza...\" >> career-ops/.env\n```\n\nOr set them via the **App settings** page in the UI (`/#/config`\n\n) — same\nfile, masked-on-read, applied to `process.env`\n\nimmediately.\n\nRefresh the Health page — every required check should be green. Then:\n\n- Click\n**🌐 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.\n- Filter by stack chips (PHP / Go / Backend / Senior) until you see something promising.\n- Copy the URL → paste it into\n**Pipeline**→ click** Evaluate**to score it 0-5 live (Anthropic / Gemini) or get a manual prompt. - Reports land in\n`reports/`\n\n, tracker in`data/applications.md`\n\n, live deep-research in`interview-prep/`\n\n. All visible in the UI.\n\nTranslations of this guide live in each language-specific README:\n\n[Español]·[Français]·[Português (Brasil)]·[한국어]·[日本語]·[Русский]·[简体中文]·[繁體中文]\n\nMIT. See [LICENSE](/Fighter90/career-ops-ui/blob/main/LICENSE).\n\nBuilt on top of [career-ops](https://github.com/santifer/career-ops) by [santifer](https://santifer.io). Thanks for the brilliant pipeline.\n\nThanks to everyone who helps build career-ops-ui. The project is maintained by [Fighter90](https://github.com/Fighter90) and improved by community contributions — see the full list on the [contributors graph](https://github.com/Fighter90/career-ops-ui/graphs/contributors).", "url": "https://wpnews.pro/news/show-hn-a-local-first-job-search-command-center-no-cloud-no-telemetry", "canonical_source": "https://github.com/Fighter90/career-ops-ui", "published_at": "2026-06-13 14:59:48+00:00", "updated_at": "2026-06-13 15:17:35.346593+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "artificial-intelligence"], "entities": ["Fighter90", "career-ops", "career-ops-ui", "Claude Code", "Codex", "OpenCode", "Qwen CLI", "Playwright MCP"], "alternates": {"html": "https://wpnews.pro/news/show-hn-a-local-first-job-search-command-center-no-cloud-no-telemetry", "markdown": "https://wpnews.pro/news/show-hn-a-local-first-job-search-command-center-no-cloud-no-telemetry.md", "text": "https://wpnews.pro/news/show-hn-a-local-first-job-search-command-center-no-cloud-no-telemetry.txt", "jsonld": "https://wpnews.pro/news/show-hn-a-local-first-job-search-command-center-no-cloud-no-telemetry.jsonld"}}