swissdevjobs-cli

skill
Guvenlik Denetimi
Gecti
Health Gecti
  • License — License: MIT
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Community trust — 18 GitHub stars
Code Gecti
  • Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Gecti
  • Permissions — No dangerous permissions requested

Bu listing icin henuz AI raporu yok.

SUMMARY

Zero-dependency CLI for swissdevjobs.ch — search Swiss dev jobs with salary data, apply, and track applications. Ships a Claude Code skill.

README.md

🇨🇭 swissdevjobs-cli

Search, filter, and apply to ~4,700 tech jobs across 7 countries — with salary data — without leaving your terminal.

License: MIT
Python
Dependencies
Boards
Claude Code plugin
MCP
CI


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. This CLI puts all six feeds in your shell —
searchable, sortable, scriptable, and 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
14 shown · 14 match filters · 4681 in feed · 3 hidden (already applied)
----------------------------------------------------------------------------------------
686f2a1c…  ch   p=2026-08-19 a=2026-08-22  Senior Platform Engineer   Acme AG      Zurich   CHF 145'000–170'000  remote  Kubernetes, Go, Terraform
6a8caf89…  de   p=2026-08-24               Senior DevOps Engineer     Beispiel AG  Berlin   EUR 95'000–115'000   remote  Kubernetes, AWS, Python

Contents


Why

🌍 Six boards, one tool Switzerland, Germany, UK, US/Canada, Netherlands, France — same backend family, 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 dates The 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 applied Local SQLite. Applied jobs vanish from list automatically
🤖 Agent-native An MCP server plus --json on every command; duplicates come back as data, not errors
📦 Zero dependencies Python stdlib only. No requests, no pydantic, no supply chain
🎯 Refuses to black-hole your application Detects postings the site can't actually deliver and tells you where to apply instead

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

Six boards, one backend, one tool. swissdevjobs.ch's operator runs the same
platform in five more countries — identical API, identical apply flow — so one
client covers all of them:

Board Country Currency Direct apply
🇨🇭 swissdevjobs.ch Switzerland CHF
🇩🇪 germantechjobs.de Germany EUR
🇬🇧 devitjobs.uk United Kingdom GBP
🇺🇸🇨🇦 devitjobs.com US & Canada USD
🇳🇱 devitjobs.nl Netherlands EUR
🇫🇷 devitjobs.fr France EUR

All boards are searched by default. Narrow per command with --country, or
persist a subset:

sdj list --country de --country uk        # just Germany + UK, this once
sdj config --countries ch,de              # persist: only search CH + DE
sdj config --countries all                # back to everything

Every board enforces published salary ranges, and 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)
# SDJ_COUNTRIES=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
variable purpose
SDJ_NAME applicant full name for direct-apply
SDJ_EMAIL applicant email for direct-apply
SDJ_CV default CV path, so you can omit --cv
SDJ_ENV_FILE explicit .env location, checked first
SDJ_CONFIG_DIR override ~/.config/swissdevjobs-cli (cookie jar, .env)
SDJ_CACHE_DIR override ~/.cache/swissdevjobs-cli (SQLite database)
SDJ_APPLICATIONS_LOG markdown application log to import on first run

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 tech --limit 20                                 # what the market wants

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

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

p= posted — real creation time, decoded from the ObjectId. Immutable.
a= activeactiveFrom, 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.

In both cases the CLI exits 2 and hands you the real apply URL rather than letting
you believe you applied. --force overrides 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

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

Filtering

flag effect
--country ch (repeatable) which boards to search; defaults to your enabled set
--tech X (repeatable) match any listed tag; add --tech-all to require all of them
--location Zurich substring match on city
--remote / --onsite remote+hybrid only / exclude remote
--visa visa sponsorship only
--level Junior · Regular · Senior · Principal · CLevel
--language posting language, e.g. English, German
--min-salary / --max-salary per year, in the board's currency
--company substring match
--sort posted (default) · date · salary · company
--limit N hard cap on rows
--page N --per-page N windowed output instead
--include-applied stop hiding jobs you've already applied to
--refresh bypass the cache, and bust Cloudflare's edge cache too
--json machine-readable
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

tool what it does read-only
search_jobs filter by pay, stack, city, country, remote, seniority, visa
get_job full posting: description, requirements, screening questions
apply_to_job submit through the site's own form — gated
list_applications everything recorded locally
mark_applied record an application made by email or on an ATS
top_technologies what 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/>6 boards by country"]
        DEVIT["boards/worldwide/devitjobs<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
    PERS -.implements.-> PORTS
    DEVIT --> HTTP
    DEVIT --> MODEL
    PERS --> SQL[("~/.cache/…/swissdevjobs.db")]
    HTTP --> NET(["6 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,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

endpoint purpose
GET /api/jobsLight every active job, lightweight fields
GET /api/job/{_id} full detail: description, responsibilities, requirements
GET /rss RSS feed, an alternate bulk source
POST /api/jobApply the 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 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

All six boards sit behind Cloudflare. 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 ISO country code
      worldwide/devitjobs/   Client + anti-corruption layer for the 6-board family
    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/               172 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 + 172 tests with a 90% coverage gate
make test-live  # optional: read-only smoke against all six 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.


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.


License

MIT — see LICENSE. Not affiliated with, endorsed by, or connected to
swissdevjobs.ch.

Yorumlar (0)

Sonuc bulunamadi