claudeers.
// MCP Servers

swissdevjobs-cli

Search & apply to ~4,700 salary-transparent tech jobs across 7 countries (πŸ‡¨πŸ‡­πŸ‡©πŸ‡ͺπŸ‡¬πŸ‡§πŸ‡ΊπŸ‡ΈπŸ‡¨πŸ‡¦πŸ‡³πŸ‡±πŸ‡«πŸ‡·) from your terminal or AI agent β€” zero-dependency Pyth…

// MCP Servers[ cli ][ api ][ desktop ][ web ][ mobile ][ claude ]#claude#ai-agents#claude-code#cli#developer-jobs#france#germany#job-application#mcp-serversβ—· MIT$open-sourceupdated about 1 month ago
Actively maintained
99/100
last commit about 1 month ago
last release about 1 month ago
releases 10
open issues 1
// star history

Install with your AI

Paste into Claude Code, Cursor, or any agent β€” it reads the repo and wires the tool into your project.

Install and set up swissdevjobs-cli (claude-plugin project) into my current project.
Found on https://claudeers.com/swissdevjobs-cli
Repo: https://github.com/Stupidoodle/swissdevjobs-cli
Homepage/docs: β€”
Detected install method: claude-plugin β†’ /plugin install swissdevjobs-cli@Stupidoodle/swissdevjobs-cli
Category: mcp-servers. Platforms: cli, api, desktop, web, mobile.
Read the repo's README for exact setup and env vars, then install it and wire it into my project.

Claudeers Health Verdict:
active; community-verified: false. Confirm the source before running anything.
// or install directly (claude-plugin)
/plugin marketplace add Stupidoodle/swissdevjobs-cli
/plugin install swissdevjobs-cli@Stupidoodle/swissdevjobs-cli
// or clone
git clone https://github.com/Stupidoodle/swissdevjobs-cli

// compatibility

Platformscli, api, desktop, web, mobile
Operating systemsβ€”
AI compatibilityclaude
LicenseMIT
Pricingopen-source
LanguagePython

Get your FREE $2.50 API credits to access TickAtlas financial data β†—

πŸ‡¨πŸ‡­ swissdevjobs-cli

Search, filter, and apply across 8 job boards in 7 countries β€” ~4,700 tech jobs with salary data, plus all ~50,000 Swiss postings on jobs.ch & jobup.ch β€” without leaving your terminal.

πŸ‡¨πŸ‡­ Switzerland Β· πŸ‡©πŸ‡ͺ Germany Β· πŸ‡¬πŸ‡§ UK Β· πŸ‡ΊπŸ‡ΈπŸ‡¨πŸ‡¦ US & Canada Β· πŸ‡³πŸ‡± Netherlands Β· πŸ‡«πŸ‡· France


Every posting on swissdevjobs.ch β€” and its five sister boards covering Germany, the UK, the US & Canada, the Netherlands, and France β€” is required to publish a salary range. That makes them the rare job boards where you can filter by pay before you click. Since v0.5 the tool also searches jobs.ch and jobup.ch β€” Switzerland's two biggest boards, every industry, ~50,000 postings. One search, eight boards, JSON-first so an LLM agent can drive it.

It also remembers what you've already applied to, across every board, so the same job never shows up twice.

$ sdj list --tech Kubernetes --remote --min-salary 90000 --sort salary
57 shown Β· 57 match filters Β· 5521 in feed Β· 3 hidden (already applied)
----------------------------------------------------------------------------------------
6a60ec82…  devitjobs-us    p=2026-07-22  Senior Maximo Systems Developer/Integrator  TEKsystems c/o Allegis Gr  Ottawa   USD 208'000–270'400  remote  API, Ansible, Bash, ESB, GIS, IBM
6a6cc9bd…  devitjobs-us    p=2026-07-31  Senior Solutions Architect                  TEKsystems c/o Allegis Gr  Calgary  USD 187'200–228'800  remote  AI, API, Architect, Cloud, Docker

Contents


Why

🌍 Eight boards, one toolThe all-IT devitjobs family across six countries, plus jobs.ch & jobup.ch for every industry in Switzerland β€” one search, per-board currencies
πŸ’° Salary is a first-class filter--min-salary 130000 β€” no more opening 40 tabs to find the range
πŸ“… Real posting datesThe site re-stamps activeFrom when it bumps a listing. This decodes the true creation time from the MongoDB ObjectId, so a "new" job that's actually four months old can't fool you
🧠 Remembers where you appliedLocal SQLite. Applied jobs vanish from list automatically
πŸ€– Agent-nativeAn MCP server plus --json on every command; duplicates come back as data, not errors
πŸ“¦ Zero dependenciesPython stdlib only. No requests, no pydantic, no supply chain
🎯 Refuses to black-hole your applicationDetects postings the site can't actually deliver and tells you where to apply instead

Please be reasonable

This talks to somebody else's website, built by a small team who chose to make salary transparency mandatory. Keep your request volume human. Don't strip the caching. Don't fire off applications to postings you haven't read β€” that wastes a real recruiter's afternoon and poisons the well for everyone using the board honestly.

Read swissdevjobs.ch's terms before you automate anything on top of this.


Install

In Claude Code β€” two commands

/plugin marketplace add Stupidoodle/swissdevjobs-cli
/plugin install swissdevjobs@swissdevjobs

That's it. Claude Code prompts for your name, email, and CV path, then starts the MCP server with uvx β€” nothing to install first, and no shell profile is touched. Ask it "find me senior Python roles in Zurich over 140k" and go.

Requires uv on your PATH (brew install uv, or curl -LsSf https://astral.sh/uv/install.sh | sh).

As a CLI

uv tool install git+https://github.com/Stupidoodle/swissdevjobs-cli
Other ways
# pipx
pipx install git+https://github.com/Stupidoodle/swissdevjobs-cli

# from a checkout, editable
git clone https://github.com/Stupidoodle/swissdevjobs-cli
cd swissdevjobs-cli
uv tool install -e .     # or: pipx install -e .

# no install at all β€” run it once
uvx --from git+https://github.com/Stupidoodle/swissdevjobs-cli sdj list --remote

Not published to PyPI.

Installs two equivalent binaries: sdj and swissdevjobs. Python 3.9+.


Boards

Two platforms, eight boards, one tool. The devitjobs family shares one backend across six countries β€” identical API, identical apply flow β€” and the JobCloud platform (jobs.ch, jobup.ch) covers every industry in Switzerland:

BoardCountryScopeCurrencySalary dataDirect apply
πŸ‡¨πŸ‡­swissdevjobs.chSwitzerlandITCHFalwaysβœ…
πŸ‡©πŸ‡ͺgermantechjobs.deGermanyITEURalwaysβœ…
πŸ‡¬πŸ‡§devitjobs.ukUnited KingdomITGBPalwaysβœ…
πŸ‡ΊπŸ‡ΈπŸ‡¨πŸ‡¦devitjobs.comUS & CanadaITUSDalwaysβœ…
πŸ‡³πŸ‡±devitjobs.nlNetherlandsITEURalwaysβœ…
πŸ‡«πŸ‡·devitjobs.frFranceITEURalwaysβœ…
πŸ‡¨πŸ‡­jobs.chSwitzerlandall industriesCHFnone🌐 via ATS
πŸ‡¨πŸ‡­jobup.chSwitzerland (Romandie)all industriesCHFnone🌐 via ATS

All boards are searched by default. A selector is a board id (just that one) or a country code (every board there) β€” --board, with --source and --country as accepted aliases:

sdj list --board jobsch                   # just jobs.ch
sdj list --board de --board uk            # just Germany + UK, this once
sdj config --boards ch,de                 # persist: all CH boards + Germany
sdj config --boards swissdevjobs          # persist: one board only
sdj config --boards all                   # back to everything

sdj boards (and the MCP tool list_boards) prints all of this as data β€” no need to memorize which board does what:

$ sdj boards
8 boards β€” select with --board <id|country>, persist with `sdj config --boards`
----------------------------------------------------------------------------------------------------
swissdevjobs     ch   SwissDevJobs        CHF  enabled  it Β· salary Β· unfilterable: workload
germantechjobs   de   GermanTechJobs      EUR  enabled  it Β· salary Β· unfilterable: workload
devitjobs-uk     uk   DevITjobs UK        GBP  enabled  it Β· salary Β· unfilterable: workload
devitjobs-us     us   DevITjobs US/CA     USD  enabled  it Β· salary Β· unfilterable: workload
devitjobs-nl     nl   DevITjobs NL        EUR  enabled  it Β· salary Β· unfilterable: workload
devitjobs-fr     fr   DevITjobs FR        EUR  enabled  it Β· salary Β· unfilterable: workload
jobsch           ch   jobs.ch             CHF  enabled  all-industries Β· no-salary Β· search-driven Β· no-native-apply Β· unfilterable: salary, remote, visa, level, tech Β· categories: it
jobup            ch   jobup.ch            CHF  enabled  all-industries Β· no-salary Β· search-driven Β· no-native-apply Β· unfilterable: salary, remote, visa, level, tech Β· categories: it

jobs.ch and jobup.ch are search-driven. Their ~50k-job inventory can't be mirrored (the API caps at 20 rows per page and 2,000 results per query), so they answer your query server-side, newest first β€” pass free text for real coverage, and --category it to stay in tech:

$ sdj list "pflegefachfrau" --board jobsch --limit 2      # any industry, server-side
2 shown Β· 100 match filters Β· 100 in feed
----------------------------------------------------------------------------------------
40b2f214…  jobsch  p=2026-08-25  Dauernachtwache - Dipl. Pflegefachperson HF / FH  Stiftung entero      Niederlenz  β€”
b5367506…  jobsch  p=2026-08-25  Dipl. Pflegefachfrau HF/FH mit Fachverantwortung  Spital MΓ€nnedorf AG  MΓ€nnedorf   β€”

$ sdj list "python" --board ch --category it              # CH tech across all three boards

They publish no salary data (rendered honestly as β€”) and have no native apply β€” every posting routes to the company's own ATS, so direct-apply refuses with the real apply URL instead of pretending.

Native postings on every devitjobs board publish a salary range. Syndicated listings (marked isPartner by the boards β€” the majority outside Switzerland) sometimes carry no range or a single-point figure; the tool renders those honestly and refuses to native-apply to them, handing you the real ATS URL instead β€” their pages have no native apply form, so a native submission would silently vanish. The applied-jobs ledger is shared β€” apply to a role on one board and the same company+role is hidden on all of them.

Want a board outside the family? Open a board request.


Configure

Reading jobs needs no configuration at all. Applying needs to know who you are.

sdj config --init     # writes ~/.config/swissdevjobs-cli/.env (chmod 600)
sdj config            # show what's resolved, and from where

Then edit the file:

SDJ_NAME="Your Name"
SDJ_EMAIL="[email protected]"
SDJ_CV="/absolute/path/to/cv.pdf"

# Optional: which boards to search (default: all)
# Board ids and/or country codes: jobsch, jobup, swissdevjobs,
# germantechjobs, devitjobs-*, ch, de, uk, us, nl, fr
# SDJ_BOARDS=ch,de

Where settings come from

.env files are read stdlib-only β€” no python-dotenv dependency. Anything already exported in your shell always wins, so nothing on disk can silently shadow it.

flowchart TD
    A["1 Β· Command-line flag<br/>--name / --email / --cv"]
    B["2 Β· Real environment<br/>SDJ_NAME=… sdj …"]
    C["3 Β· $SDJ_ENV_FILE"]
    D["4 Β· ./.env<br/>walking up to /"]
    E["5 Β· ~/.config/swissdevjobs-cli/.env"]
    F["direct-apply refuses<br/>run: sdj config --init"]

    A -->|"not set"| B
    B -->|"not set"| C
    C -->|"not set"| D
    D -->|"not set"| E
    E -->|"still not set"| F

    classDef win fill:#c7f0d8,stroke:#1a7f45,color:#0b3d22
    classDef mid fill:#dbe7ff,stroke:#2a5db0,color:#12233f
    classDef low fill:#f0f0f4,stroke:#8a8a99,color:#2a2a33
    classDef bad fill:#ffd6d6,stroke:#c0392b,color:#4a1210

    class A win
    class B mid
    class C,D,E low
    class F bad

Highest priority at the top. A project-local .env beats the global one, which is handy if you keep a separate identity per job search.

Full variable reference
variablepurpose
SDJ_NAMEapplicant full name for direct-apply
SDJ_EMAILapplicant email for direct-apply
SDJ_CVdefault CV path, so you can omit --cv
SDJ_ENV_FILEexplicit .env location, checked first
SDJ_CONFIG_DIRoverride ~/.config/swissdevjobs-cli (cookie jar, .env)
SDJ_CACHE_DIRoverride ~/.cache/swissdevjobs-cli (SQLite database)
SDJ_APPLICATIONS_LOGmarkdown application log to import on first run
SDJ_JOBCLOUD_PAGESpages fetched per jobs.ch/jobup.ch search (default 5 β†’ 100 rows/board)

Commands

flowchart TD
    START(["sdj"]) --> DISCOVER["πŸ” Discover"]
    START --> ACT["βœ‰οΈ Act"]
    START --> TRACK["πŸ“Š Track"]

    DISCOVER --> L["list<br/>search and filter the feed"]
    DISCOVER --> S["show<br/>full posting text"]
    DISCOVER --> T["tech<br/>most-wanted tech tags"]
    DISCOVER --> O["open<br/>posting in your browser"]

    ACT --> A["apply<br/>how do I apply to this one?"]
    ACT --> DA["direct-apply<br/>submit through the site's form"]
    ACT --> AU["auth<br/>clear a Cloudflare challenge"]

    TRACK --> AP["applications<br/>everything you have sent"]
    TRACK --> ST["stats<br/>cache and application counts"]
    TRACK --> CF["config<br/>resolved settings and paths"]

    classDef root fill:#8A63D2,stroke:#5b3fa0,color:#ffffff
    classDef group fill:#dbe7ff,stroke:#2a5db0,color:#12233f
    classDef leaf fill:#f5f6fa,stroke:#9aa0b5,color:#22262f

    class START root
    class DISCOVER,ACT,TRACK group
    class L,S,T,O,A,DA,AU,AP,ST,CF leaf

Every command takes --json.

Discover
sdj list                                            # everything active
sdj list --tech Python --tech Kubernetes --remote   # any of those tags, remote/hybrid
sdj list --min-salary 130000 --location Zurich --sort salary
sdj list "platform engineer" --level Senior --visa  # free text + visa sponsorship
sdj list --company Google --include-applied         # include ones you've done

sdj show 686f2a1c57370f0152e4950e                   # by id
sdj show senior-platform-engineer-acme              # …or by slug, or a substring
sdj show acme --json                                # machine-readable

sdj open acme                                       # launch the posting
sdj boards                                          # every board, as data
sdj tech --limit 20                                 # what the market wants

list --json returns compact summary rows in an envelope β€” capped at 50 unless you pass --limit (0 = uncapped), with boards_searched and a coverage note when a search-driven board ran without a query. Empty fields are omitted and salary is numeric (salary_from/salary_to + currency). The pre-0.6 raw wire rows are still there behind --raw:

$ sdj list "python" --board swissdevjobs --json --limit 2
{
  "total_in_feed": 187,
  "total_after_filters": 28,
  "hidden_already_applied": 3,
  "returned": 2,
  "boards_searched": ["swissdevjobs"],
  "jobs": [
    {
      "job_id": "6a8d83d641e56340faa426c0",
      "title": "Senior Solutions Engineer Real Estate | Data & BIM",
      "company": "Rockstar Recruiting AG",
      "city": "Zurich",
      "salary_from": 110000,
      "salary_to": 130000,
      "currency": "CHF",
      "workplace": "hybrid",
      "contract": ["permanent"],
      "language": "German",
      "technologies": ["BIM", "CAFM", "Embedded", "ERP", "Mobile", "Python", "AI"],
      "posted_at": "2026-08-25T12:00:22+00:00",
      "country": "ch",
      "source": "swissdevjobs",
      "url": "https://swissdevjobs.ch/jobs/Rockstar-Recruiting-AG-Senior-Solutions-Engineer-Real-Estate--Data--BIM"
    },
    …
  ]
}

list columns: id Β· board Β· dates Β· title Β· company Β· city Β· salary Β· workplace Β· tags

On jobs.ch/jobup.ch rows the salary column reads β€” (the platform publishes none) and tags are usually empty β€” their coverage comes from server-side query search, not client-side tag filters.

The date column carries two values, and the difference matters:

p=posted β€” real creation time, decoded from the ObjectId. Immutable.
a=active β€” activeFrom, which the site re-stamps every time it bumps a listing back to the top

A row reading p=2026-04-02 a=2026-08-22 is a four-month-old job wearing a fresh coat of paint. Sort by --sort posted (the default) to see through it.

Act
sdj apply <id> --json                     # what route does this posting use?
sdj apply <id> --open                     # …and open the ATS while you're at it

sdj direct-apply <id> --motivation ./letter.txt
sdj direct-apply <id> --cv ./cv_de.pdf --motivation "Sehr geehrte Damen und Herren, …"
sdj direct-apply <id> --lang-skills fluent --not-eu

sdj apply <id> --complete email           # you sent it yourself β€” record it
sdj apply <id> --complete browser --notes "answered 3 screening questions"

--motivation takes inline text or a file path β€” it checks whether the string is an existing file. The letter must not contain < or >; the site rejects them.

Track
sdj applications                          # newest first
sdj applications --json --limit 500
sdj stats                                 # cached jobs, applications, db path
sdj config                                # identity + paths + which .env loaded

How applying works

Three postings on the same board can need three completely different actions. sdj apply tells you which, and direct-apply refuses the cases it knows would vanish.

flowchart TD
    START(["sdj apply JOB_ID"]) --> Q1{"redirectJobUrl points at<br/>talent.com or jometer?"}

    Q1 -->|yes| AGG["🚫 aggregator_posting<br/>exit code 2"]
    Q1 -->|no| Q2{"candidateContactWay?"}

    Q2 -->|"Email, with<br/>an address"| DIRECT["βœ… direct<br/>the site forwards it"]
    Q2 -->|"CompanyWebsite,<br/>no address"| CW["🚫 company_website_posting<br/>exit code 2"]

    AGG --> BROWSER["🌐 Go apply on the ATS<br/>Recruitee · Workday · Greenhouse<br/>Lever · Personio · SmartRecruiters"]
    CW --> BROWSER

    DIRECT --> POST["POST /api/jobApply<br/>multipart: name, email,<br/>motivation, CV PDF"]
    POST --> OK{"HTTP 200?"}
    OK -->|yes| MARK["πŸ’Ύ recorded in SQLite<br/>hidden from future list"]
    OK -->|no| ERR["❌ raised with the response body"]

    BROWSER -.->|"after you submit"| COMPLETE["sdj apply JOB_ID --complete browser"]
    COMPLETE --> MARK

    classDef start fill:#8A63D2,stroke:#5b3fa0,color:#ffffff
    classDef good fill:#c7f0d8,stroke:#1a7f45,color:#0b3d22
    classDef bad fill:#ffd6d6,stroke:#c0392b,color:#4a1210
    classDef work fill:#dbe7ff,stroke:#2a5db0,color:#12233f
    classDef store fill:#ffe9b8,stroke:#b07d1a,color:#4a3308

    class START start
    class DIRECT,OK good
    class AGG,CW,ERR bad
    class POST,BROWSER,COMPLETE work
    class MARK store

Why the refusals exist

POST /api/jobApply returns HTTP 200 even when nobody receives your application. That happens in two cases:

  1. Aggregator syndication. The listing was pulled in from talent.com or jometer. swissdevjobs.ch has no forwarding address for it.
  2. candidateContactWay == "CompanyWebsite". The site is only linking out to the company's own ATS. emailAddressForApplications is null, so there is nothing to forward to.

A third case can't even pretend: jobs.ch and jobup.ch have no native apply endpoint at all β€” every posting routes to the company's own application flow, so direct-apply there always answers no_native_apply with the ATS URL.

In every case the CLI exits 2 and hands you the real apply URL rather than letting you believe you applied. --force overrides the first two if you disagree.

$ sdj direct-apply some-workday-job --json
{
  "error": "company_website_posting",
  "next_action": "use_chrome_mcp",
  "apply_url": "https://acme.wd3.myworkdayjobs.com/…",
  "message": "USE CHROME MCP: visit … and drive the ATS form. …"
}

Exit codes

codemeaning
0success β€” including "already applied", which is data, not failure
1no match, bad arguments, missing identity, or a missing CV file
2Cloudflare challenge unresolved, or the posting needs a browser
130you hit Ctrl-C

Filtering

flageffect
--board jobsch (repeatable)board selector β€” a board id (jobsch) or a country code (ch = all three Swiss boards); --source/--country are aliases; defaults to your enabled set
--category itnarrow the all-industry boards to one category; devitjobs boards are all-IT already
--tech X (repeatable)match any listed tag; add --tech-all to require all of them
--location Zurichsubstring match on city
--remote / --onsiteremote+hybrid only / exclude remote
--visavisa sponsorship only
--levelJunior Β· Regular Β· Senior Β· Principal Β· CLevel
--languageposting language, e.g. English, German
--min-salary / --max-salaryper year, in the board's currency
--contractpermanent Β· temporary Β· freelance Β· internship Β· apprenticeship Β· supplementary β€” each board maps its own taxonomy onto these aliases
--workload 80postings offering that workload percent (jobs.ch/jobup.ch publish ranges; the devitjobs boards don't and are excluded visibly)
--companysubstring match
--sortposted (default) Β· date Β· salary Β· company
--limit Nhard cap on rows; 0 = no cap. Default: no cap for the table and --raw, 50 for --json
--page N --per-page Nwindowed output instead
--include-appliedstop hiding jobs you've already applied to
--refreshbypass the cache, and bust Cloudflare's edge cache too
--jsonsummary rows in an envelope (see Commands)
--rawwith --json: full raw wire rows in the pre-0.6 shape

Every filter behaves the same on every board β€” or tells you it can't. jobs.ch/jobup.ch publish no salary, workplace, visa, or experience-level data (their own site can't filter on those either), so filtering on one of them excludes those boards visibly: the JSON envelope carries boards_excluded and a note, the table prints the note on stderr β€” never a silent empty result. --tech still works there: the terms are matched server-side as full-text query (multi-term queries AND together), and --contract/--workload filter server-side through the platform's own taxonomy. sdj boards shows each board's unavailable dimensions and contract aliases as data.

Why --refresh does more than skip the local cache

/api/jobsLight is served with Cache-Control: max-age=3600, and Cloudflare will happily return a HIT that's many hours stale β€” an Age of ~80'000 s has been observed in the wild, which hides everything posted that day. --refresh appends a unique query string so the request lands on a distinct cache key, forcing a MISS and origin-fresh data.


MCP server

In Claude Code, the plugin wires this up for you. For any other MCP client, point it at swissdevjobs-mcp:

// Claude Code: .mcp.json  Β·  Claude Desktop: claude_desktop_config.json
{
  "mcpServers": {
    "swissdevjobs": {
      "command": "swissdevjobs-mcp",
      "env": {
        "SDJ_NAME": "Your Name",
        "SDJ_EMAIL": "[email protected]",
        "SDJ_CV": "/absolute/path/to/cv.pdf"
      }
    }
  }
}

No prior install needed if you have uv β€” swap the command for uvx and let it fetch:

{
  "command": "uvx",
  "args": ["--from", "git+https://github.com/Stupidoodle/swissdevjobs-cli", "swissdevjobs-mcp"]
}

Then just ask: "find me senior Python roles in Zurich over 140k" β€” or "remote roles in Germany or the UK paying over 80k, show me the top five."

Tools

toolwhat it doesread-only
search_jobsfilter by pay, stack, city, country, remote, seniority, visaβœ…
list_boardsevery board as data: scope, currency, salary, categories, apply capabilityβœ…
get_jobfull posting: description, requirements, screening questionsβœ…
apply_to_jobsubmit through the site's own form β€” gated❌
list_applicationseverything recorded locallyβœ…
mark_appliedrecord an application made by email or on an ATS❌
top_technologieswhat the market is asking for right nowβœ…

The read-only tools carry readOnlyHint, so a client can run them without interrupting you. search_jobs returns compact rows on purpose β€” full descriptions come from get_job, so a broad search doesn't burn context.

The confirmation gate

An application cannot be unsent, so apply_to_job refuses to submit until it is called a second time with confirm: true. The first call returns exactly what would go out:

{
  "error": "confirmation_required",
  "would_submit": {
    "role": "Senior ML Engineer",
    "company": "Acme AG",
    "salary": "CHF 140'000–180'000",
    "applicant": { "name": "…", "email": "…" },
    "cv_path": "/…/cv.pdf",
    "motivation_preview": "Dear hiring team, …",
    "motivation_chars": 1180
  }
}

The assistant shows you that, you say yes, and only then does anything leave your machine. Duplicates and undeliverable postings are caught before the gate, so a repeat never turns into a second submission.

sequenceDiagram
    autonumber
    participant U as you
    participant M as assistant
    participant S as MCP server
    participant SDJ as swissdevjobs.ch

    U->>M: "apply to the Acme role"
    M->>S: apply_to_job(job_id, motivation, cv_path)
    S->>S: already applied? deliverable? CV exists?
    S-->>M: confirmation_required + would_submit
    M-->>U: role, salary, letter preview β€” send it?
    U->>M: yes
    M->>S: apply_to_job(…, confirm: true)
    S->>SDJ: POST /api/jobApply
    SDJ-->>S: 200
    S->>S: record it locally
    S-->>M: submitted
    M-->>U: applied, and hidden from future searches

Under the hood

flowchart TB
    subgraph EP["entrypoints"]
        CLI["cli.py<br/>argparse commands"]
        MCP["mcp.py<br/>JSON-RPC over stdio"]
    end
    subgraph SL["service_layer"]
        SEARCH["search"]
        APPLY["apply"]
        TRACK["tracking"]
    end
    subgraph AD["adapters"]
        REG["boards/registry<br/>8 boards by source"]
        DEVIT["boards/worldwide/devitjobs<br/>client + ACL"]
        JC["boards/switzerland/jobcloud<br/>client + ACL"]
        HTTP["http/client<br/>urllib, cookies, CF detection"]
        PERS["persistence<br/>mappers, repos, SQLite UoW"]
    end
    subgraph DOM["domain"]
        MODEL["model: Job, Board,<br/>Salary, Application"]
        PORTS["ports: BoardPort,<br/>repositories, UoW"]
    end

    CLI --> SEARCH
    MCP --> SEARCH
    CLI --> APPLY
    MCP --> APPLY
    SEARCH --> PORTS
    APPLY --> PORTS
    TRACK --> PORTS
    DEVIT -.implements.-> PORTS
    JC -.implements.-> PORTS
    PERS -.implements.-> PORTS
    DEVIT --> HTTP
    JC --> HTTP
    DEVIT --> MODEL
    JC --> MODEL
    PERS --> SQL[("~/.cache/…/swissdevjobs.db")]
    HTTP --> NET(["8 boards, 7 countries"])

    classDef mod fill:#dbe7ff,stroke:#2a5db0,color:#12233f
    classDef ext fill:#ffe9b8,stroke:#b07d1a,color:#4a3308
    classDef store fill:#c7f0d8,stroke:#1a7f45,color:#0b3d22

    class CLI,MCP,SEARCH,APPLY,TRACK,REG,DEVIT,JC,HTTP,PERS,MODEL,PORTS mod
    class NET ext
    class SQL store

The layering is cosmic-python style β€” domain at the center, adapters around it, entrypoints on the edge β€” and it is enforced, not aspirational: import-linter contracts plus an ast-based architecture test fail the build on any inward-pointing violation. The domain layer imports nothing but the stdlib.

Request path

sequenceDiagram
    autonumber
    participant U as you
    participant CLI as sdj
    participant DB as SQLite
    participant CF as Cloudflare
    participant API as the board

    U->>CLI: sdj list --tech Python
    CLI->>DB: cached jobs younger than 10 min?
    alt cache is fresh
        DB-->>CLI: rows
        CLI-->>U: filtered table
    else stale or --refresh
        CLI->>CF: GET /api/jobsLight
        alt normal
            CF->>API: forward
            API-->>CF: JSON
            CF-->>CLI: JSON
            CLI->>DB: upsert + timestamp
            CLI-->>U: filtered table
        else challenge
            CF-->>CLI: "Just a moment…" / cf-mitigated
            CLI-->>U: opens browser, waits on stdin
            U->>CLI: pastes cf_clearance
            CLI->>CLI: store in cookie jar, retry once
        end
    end

Reverse-engineered API surface

endpointpurpose
GET /api/jobsLightevery active job, lightweight fields
GET /api/job/{_id}full detail: description, responsibilities, requirements
GET /rssRSS feed, an alternate bulk source
POST /api/jobApplythe site's own apply form, multipart/form-data

No auth, no API key on the read endpoints. The same surface exists on every board of the family. Responses cached in SQLite per board β€” 10 min for the list, 1 h for detail.

The JobCloud platform (jobs.ch, jobup.ch) exposes a different, equally open surface β€” shared by both boards, each with its own category taxonomy:

endpointpurpose
GET /api/v1/public/searchquery, rows (≀20), page (≀2,000 results), sort, category-ids[]
GET /api/v1/public/search/job/{id}full detail incl. apply method and ATS URL

Search-driven boards always ask the server; their SQLite rows exist so show and apply can resolve what a past search surfaced.

The database

erDiagram
    JOBS {
        text _id PK "MongoDB ObjectId"
        text source "which board (unique with job_url)"
        text job_url "slug"
        text company
        text name "role title"
        int  annual_salary_from
        int  annual_salary_to
        text candidate_contact_way "Email | CompanyWebsite"
        text email_address "null when external"
        text redirect_url "the ATS link"
        text light_json "full normalized feed row"
        text detail_json "full detail payload"
        text light_fetched_at
        text detail_fetched_at
    }
    APPLICATIONS {
        int  id PK
        text job_id FK "unique β€” this is the dedup key"
        text company
        text role
        text method "direct | email | browser | linkedin"
        text status "submitted"
        text applied_at
        text notes
    }
    JOBS ||--o| APPLICATIONS : "applied to"

Deduplication runs on job_id first, then falls back to (company, role) so an application you made through LinkedIn β€” or on a different board β€” still suppresses the same role everywhere. Cache freshness and slug uniqueness are per board (UNIQUE(source, job_url)).


Cloudflare

The six devitjobs boards sit behind Cloudflare (jobs.ch/jobup.ch run AWS WAF instead, which has not challenged API traffic so far). Ordinary use sails through; bursts and datacenter IPs can trip a managed challenge.

There is no automated solver here, by design. A headless client can't run the JS challenge, and shipping something that tried would be both fragile and rude. Instead the CLI hands the problem to a real human in a real browser:

  1. The command blocks and prints the URL.
  2. Your default browser opens it.
  3. You clear the challenge, then copy cf_clearance from DevTools β†’ Application β†’ Cookies (on the challenged board's domain).
  4. Paste it back. It's stored in a Netscape cookie jar at ~/.config/swissdevjobs-cli/cookies.txt and the original request retries.

Run sdj auth up front before scripting a batch of calls.


Claude Code skill

The plugin already bundles a skill that teaches the whole search β†’ shortlist β†’ apply loop, including the confirmation handshake. Install that and you're done.

skill/SKILL.md is the standalone version, for driving the CLI without the plugin:

mkdir -p ~/.claude/skills/swissdevjobs
cp skill/SKILL.md ~/.claude/skills/swissdevjobs/

Either way the rules are the same: stop before every irreversible submit and ask, never type national ID or bank details into a form, hand CAPTCHAs back to you.


Layout

src/swissdevjobs_cli/
  bootstrap.py       Composition root β€” the only place layers get wired
  domain/
    model/           One dataclass per file: Job, JobDetail, Board, Salary, …
    ports/           One Protocol per file: BoardPort, repositories, UnitOfWork
  adapters/
    http/            urllib transport, cookie jar, Cloudflare detection
    boards/
      registry.py    Every board, keyed by source; selectors resolve country or board id
      worldwide/devitjobs/   Client + anti-corruption layer for the 6-board family
      switzerland/jobcloud/  Client + ACL for jobs.ch + jobup.ch (search-driven)
    persistence/     Schema, imperative mappers, repositories, SQLite UnitOfWork
    envfile.py       Stdlib .env loading with shell-wins precedence
  service_layer/     Use cases: search, apply, tracking, config
  dto/               The frozen entrypoint-facing shapes (plain dataclasses)
  entrypoints/       cli.py (argparse) and mcp.py (JSON-RPC 2.0 over stdio)
skill/SKILL.md       Standalone Claude Code skill
plugin/              Claude Code plugin: manifest, .mcp.json, bundled skill
.claude-plugin/      Marketplace manifest, so the repo installs itself
tests/               230 offline tests mirroring src β€” fakes per port, no mocks,
                     ast architecture checks, 90% coverage gate; opt-in live lane

Development

make install    # uv sync
make check      # ruff + ty + import-linter + 250 tests with a 90% coverage gate
make test-live  # optional: read-only smoke against all eight real boards

CI runs the same gate on Python 3.9 and 3.14, and starts the MCP server to verify it still completes a handshake. Architecture rules and contributor ground rules live in CLAUDE.md and CONTRIBUTING.md.


License

MIT β€” see LICENSE. Not affiliated with, endorsed by, or connected to swissdevjobs.ch.

// faq

What is swissdevjobs-cli?

Search & apply to ~4,700 salary-transparent tech jobs across 7 countries (πŸ‡¨πŸ‡­πŸ‡©πŸ‡ͺπŸ‡¬πŸ‡§πŸ‡ΊπŸ‡ΈπŸ‡¨πŸ‡¦πŸ‡³πŸ‡±πŸ‡«πŸ‡·) from your terminal or AI agent β€” zero-dependency Python CLI + MCP server + Claude Code plugin. It is open-source on GitHub.

Is swissdevjobs-cli free to use?

swissdevjobs-cli is open-source under the MIT license, so it is free to use.

What category does swissdevjobs-cli belong to?

swissdevjobs-cli is listed under mcp-servers in the Claudeers registry of Claude-compatible tools.

4 views
β˜… 101 stars
unclaimed
updated about 1 month ago

// embed badge

swissdevjobs-cli on Claudeers
[![Claudeers](https://claudeers.com/api/badge/swissdevjobs-cli.svg)](https://claudeers.com/swissdevjobs-cli)

// retro hit counter

swissdevjobs-cli hit counter
[![Hits](https://claudeers.com/api/counter/swissdevjobs-cli.svg)](https://claudeers.com/swissdevjobs-cli)

// reviews

// guestbook

0/500

// related in MCP Servers

πŸ”“

f.k.a. Awesome ChatGPT Prompts. Share, discover, and collect prompts from the community. Free and open source β€” self-host for your organization with complete…

// mcp-serversf/⟨HTMLβŸ©β˜… 172,096β—· NOASSERTION[ claude ]
πŸ”“

A cross-platform desktop All-in-One assistant for Claude Code, Codex, OpenCode, OpenClaw, Gemini CLI & Hermes Agent. Only official website: ccswitch.io

// mcp-serversfarion1231/⟨RustβŸ©β˜… 140,512β—· MIT[ claude ]
πŸ”“

πŸͺ¨ why use many token when few token do trick β€” Claude Code skill that cuts 65% of tokens by talking like caveman

// mcp-serversJuliusBrussee/⟨JavaScriptβŸ©β˜… 107,719β—· MIT[ claude ]
πŸ”“

An open-source AI agent that brings the power of Gemini directly into your terminal.

// mcp-serversgoogle-gemini/⟨TypeScriptβŸ©β˜… 107,167β—· Apache-2.0[ claude ]
β†’ see how swissdevjobs-cli connects across the ecosystem