Repository navigation
Releases: Stupidoodle/swissdevjobs-cli
Release list
v0.9.2
Patch: jobs.ch and jobup.ch retired their search API β followed, and isolated
JobCloud retired /api/v1/public/search on 2026-08-28. Both boards answer 410 Gone with a [] body β no deprecation window, and the detail endpoint beside it kept serving, so nothing announced it. Because a board's transport error still propagated out of the multi-board loop, every sdj list crashed with a traceback, not just the Swiss ones.
Two fixes, and the second matters more than the first.
Search follows JobCloud to its new host. Queries now go to job-search-api.<board>/search, the API the site's own frontend calls; details stay where they were. The new host speaks camelCase, so search documents are folded back into the snake-case shape Job.raw has always exposed, with the camelCase originals kept alongside β an additive change to the wire contract, not a rename. Rows serve up to 200 per page there instead of 20, so each board now covers 500 postings per search rather than 100, in about a second.
Its parameters are the trap: old names are ignored, not rejected. category-ids[]=106 still answers 200 β with the entire unfiltered corpus. A status code proves nothing about a filter here, so the live lane asserts each server-side filter by its effect on the returned rows, on both boards.
One unreachable board no longer aborts the other eight. This is the same gap that made the v0.9.0 MyCareersFuture timeout kill keyword search everywhere, reported then by @Aneek1 in #20. A board that cannot be reached is now skipped and named: sdj list --json and the search_jobs MCP tool report it as boards_failed, the in-band note says the results are partial, and the plain CLI prints it to stderr. Silence was never an option β an empty list reads as "searched, nothing matched". If every board fails, the error still raises.
One capability regressed with the endpoint: search rows no longer carry language (languageIds is empty on every row sampled), so --language now excludes jobs.ch and jobup.ch visibly instead of silently dropping their rows. The new host accepts languageIds=de server-side, so this is restorable additively.
310 tests, 93% branch coverage, live lane green on all nine boards.
Full Changelog: v0.9.1...v0.9.2
v0.9.1 β fix: keyword search aborted by MyCareersFuture timeouts
Patch: keyword search was broken by v0.9.0 β fixed
v0.9.0 introduced a regression, found and fixed by @Aneek1 in #20 within a day: MyCareersFuture's GET /v2/jobs?search= endpoint stalls or times out (HTTP 504 after ~29s) for any query not already warm in its CDN. Because the board is search-driven and multi-board search had that fetch in its path, one Singapore timeout could abort every keyword search β sdj list "java" returned zero rows from all nine boards. Unqueried browsing was unaffected.
Queries now go to POST /v2/search, which answers the same queries in about a second. The GET feed stays for unqueried browsing, where it works and carries the richer payload. Verified before merge: cold queries answer in ~1s via POST (11β29s or 504 via GET), and a cross-board sdj list "java" completes in ~4s.
Also in this patch, from re-verification against live rows:
- Agency postings now name the real employer. When
isPostedOnBehalfis set,hiringCompanyis the employer andpostedCompanyis the agency;isHideHiringEmployerNameis honoured. apply_urlis no longer null on MyCareersFuture refusals β the posting page is where applying actually happens (the portal's own flow; no external ATS links exist in sampled descriptions), so the refusal hands back that URL, as the docs always promised.Telecommutingnow maps tohybrid, notremoteβ the wire lists arrangements an employer offers, which is weaker than a remote-first claim.--remotestill matches;--onsiteno longer discards postings that merely offer telecommuting.- Descriptions keep the wire's own HTML in the cache (DTOs strip at render, same as JobCloud) instead of being flattened lossily at ingest.
- Docs corrected: the apply-instructions wording was JobCloud's situation copy-pasted onto a board where it isn't true, and the Singapore CV guidance no longer suggests the Letter of Consent route for Dependant's Pass holders (withdrawn May 2021; it still applies to LTVP/LTVP+ holders).
Search rows from the POST path carry only the re-stampable posting date; those rows are flagged postedAtIsBumpable so nothing mistakes a bumped date for a first-published one.
303 tests, 93% branch coverage, live lane green including the queries that previously timed out.
Full Changelog: v0.9.0...v0.9.1
v0.9.0 β MyCareersFuture (Singapore), first community board
MyCareersFuture (Singapore) β the first community-contributed board
Singapore's government job portal joins as the ninth board and third platform, contributed by @Aneek1 in the project's first external PR (#18). Nine boards, eight countries.
- ~9,000 active IT postings from MyCareersFuture (Workforce Singapore), via its public JSON API β no auth, no scraping.
- Search-driven, like jobs.ch/jobup.ch: the API caps results at 100 rows per page, so queries pass server-side, newest first, with a small default page budget (
SDJ_MYCAREERSFUTURE_PAGES, default 3). - Salary is published β monthly SGD on the wire, annualized by the adapter β and postings carry a skills array, so
--min-salary,--tech, and--remoteall filter normally (remotemaps from the wire'sTelecommutingarrangement, the only flexible-work value that means where rather than when). --visa,--level,--workloadexclude the board visibly, per the filter-parity contract: no sponsorship field exists (Singapore employers apply for a specific work pass after the fact), the board's own position levels have no honest mapping onto the shared level enum, and no workload percentage exists on the wire.- No native apply β apply instructions live in the posting itself, so
direct-applyrefuses with the real posting URL, same as jobs.ch/jobup.ch. - New
cv/sg.mdCV conventions bundled with the skill, covering the work-pass landscape (EP/S Pass/LOC) and Fair Consideration Framework context.
Post-merge hardening: MyCareersFuture rows now declare language: en (English-only platform by policy β previously --language en would have silently dropped them), and the registry's contract-alias lookup gained one explicit branch per platform so a future platform can't silently inherit another's aliases.
All wire-shape claims re-verified live before merge: limit cap (100; 200 is a 400), zero-based paging, salary/skills presence 100/100 on the newest page, and the posting URLs render.
291 tests, 93% branch coverage, CI on Python 3.9 + 3.14, zero runtime dependencies.
Full Changelog: v0.8.0...v0.9.0
v0.8.0 β contract & workload filters
Contract-type and workload filters, parity-checked on every board
Two new filter dimensions, both honoring the v0.7 parity contract (served, folded, or visibly excluded β never a silent empty result):
--contractβpermanentΒ·temporaryΒ·freelanceΒ·internshipΒ·apprenticeshipΒ·supplementary. Works on every board: each platform maps its own taxonomy onto these shared aliases. The devitjobs family maps itsjobType(aContractposting answers to bothfreelanceandtemporaryβ the platform folds those into one value); jobs.ch/jobup.ch filter server-side through their own employment-type taxonomy, mapped id-by-id against the labels the site renders and verified identical on both boards.--workload <pct>β postings that offer that workload percent (e.g.--workload 80). jobs.ch/jobup.ch publish structured workload ranges and filter server-side β which matters on a search-driven board, where post-fetch filtering would waste the 2,000-row result window. The devitjobs wire carries no workload percentages, so those boards are excluded visibly (boards_excluded+ note), exactly like a salary filter on jobs.ch.- Summary rows now carry
contractandworkload_from/workload_to(additive, omit-empty).list_boards/sdj boardsreport each board's contract aliases as data.
Both dimensions are covered by the registry-driven parity matrix (test_filter_parity.py) β a future board fails CI until it declares what it can and cannot serve.
250 tests, 93% branch coverage, CI on Python 3.9 + 3.14, zero runtime dependencies.
Full Changelog: v0.7.0...v0.8.0
v0.7.0 β filter parity across boards
Filter parity across all 8 boards β no more silent empty results
Filtering on data a board doesn't publish (salary, remote, visa, level on jobs.ch/jobup.ch) used to silently drop every row of that board β indistinguishable from "searched, nothing matched". jobs.ch's own site can't filter on those either; the data simply isn't on their wire (verified against the public API and their filter UI).
Now the tool tells you instead of hiding it:
boards_excludedβ searches now report boards excluded by filters their platform cannot serve, plus an in-bandnoteexplaining why and how to include them again. On MCP, onsdj list --json, and on stderr for the table view.--techworks on every board now β boards without technology tags match the terms server-side as a full-text query (multi-term queries AND together). Previously--tech Pythonsilently dropped all jobs.ch/jobup.ch rows.list_boards/sdj boardsreport each board's unavailable filter dimensions as data (filters_unavailable, additive key).- Salary filters stay fail-closed:
--min-salarymeans "prove it" β boards without salary data are excluded visibly, not silently.
The parity contract is enforced by a registry-driven test matrix (test_filter_parity.py): every board Γ every gated filter must be served, folded, or visibly excluded. A future board or filter param fails CI until classified.
245 tests, 93% branch coverage, CI on Python 3.9 + 3.14, zero runtime dependencies.
Full Changelog: v0.6.0...v0.7.0
v0.6.0 β the agent contract
v0.6.0 β the agent contract
Board facts stop being prose. The wire contract gets the fixes the v0.5
agent-UX review deferred. (CV tailoring moves to the v0.7.0 milestone.)
Boards as data
$ sdj boards
8 boards β select with --board <id|country>, persist with `sdj config --boards`
swissdevjobs ch SwissDevJobs CHF enabled it Β· salary
β¦
jobsch ch jobs.ch CHF enabled all-industries Β· no-salary Β· search-driven Β· no-native-apply Β· categories: it- New
list_boardsMCP tool andsdj boardscommand: scope,
currency, salary availability, search-driven, native apply, category
aliases, and enabled state β straight from the registry, per board. - The MCP
categoryenum and the CLI--categorychoices derive from
the registry (known_categories()). A future board registering a
financealias appears in every surface without touching prose. - The deprecated
countryparam drops its duplicated enum, and
annotations.titleno longer repeats the tool title β smaller
tools/list, one source of truth. - Skills and server instructions now say call list_boards instead of
hardcoding board facts that drift.
Payload contract (BREAKING for sdj list --json)
sdj list --jsonnow returns compact summary rows in an envelope,
capped at 50 by default (--limit 0= uncapped). The old output β full
raw wire rows, ~470 tokens each, uncapped β is preserved byte-for-byte
behind--raw. Migration: add--raw.- Summary rows are omit-empty: keys that are None or empty are dropped
(jobs.ch/jobup.ch rows shed six dead fields), and salary is
numeric-only βsalary_from/salary_topluscurrency, no
formatted string. - Stable MCP error codes:
unknown_selector,job_not_found, and
internal_errorreplace leaked Python class names (ValueErrorused to
cover two unrelated failures). - In-band coverage steering: search results name
boards_searchedand
carry anotewhen a search-driven board ran without a query
("returned newest postings only β pass query for coverage") β steering
that survives context compaction, unlike skill prose.
230 tests, 92.6% coverage, CI on 3.9 + 3.14.
Full Changelog: v0.5.1...v0.6.0
v0.5.1 β the agent-experience release
v0.5.1 β the agent-experience release
Two independent review passes over the MCP server and CLI β one measuring
real token costs, one auditing against the MCP spec (2025-06-18) and the
Claude Code plugin docs β and everything they flagged for this cycle, fixed.
Naming: --board
--country jobsch stopped making sense the moment Switzerland held three
boards. The selector flag is now --board (a board id, or a country
code that expands to every board there); --source and --country keep
working as aliases, sdj config --boards persists SDJ_BOARDS
(SDJ_COUNTRIES still read), and MCP search_jobs gains a board param
with country kept as a deprecated alias.
Token economy (measured)
- MCP results ship compact JSON β ~19% fewer tokens on every tool call
- refusal payloads no longer duplicate the (sometimes 1KB) tracking URL
search_jobs/list_applicationsdeclare
_meta["anthropic/maxResultSizeChars"], keeping max-limit results under
Claude Code's output warning- the skills now mandate
--limitonsdj list --json(raw wire rows are
~470 tokens each; uncapped worst case measured at ~10MB)
Spec compliance
- a valid-JSON non-object line (stray array, legacy batch) crashed the
server process β now a proper-32600 - tool arguments are validated against each tool's declared schema: a
typoed enum value used to silently return "0 matches" (which an agent
reads as "no jobs exist"); now it's a loudinvalid_argumentserror
naming the valid values apply_to_jobno longer claimsdestructiveHint: falseβ the spec
default applies, so clients keep every confirmation layer in front of an
irreversible submission- protocol-version negotiation,
tools/listcursor handling, and the
resources/promptsstubs now match the spec exactly
Steering fixes
- refusal messages are tool-neutral, reference
apply_urlinstead of
repeating it, spell the override per entrypoint, and never render
"visit None" again sdj applyprints the posting's real mode (was hardcodedDIRECTβ
wrong for jobs.ch/jobup.ch)- skills document
syndicated_postingβ the majority refusal outside CH - board/country counts in tool titles and instructions derive from the
registry, so prose can't drift when board #9 lands - README previews are captured from real runs
- release-pin guard tests force plugin pin and package version to move in
the same commit
Full Changelog: v0.5.0...v0.5.1
v0.5.0 β jobs.ch & jobup.ch: every Swiss industry
v0.5.0 β jobs.ch & jobup.ch: every Swiss industry, one search away
Two new boards, one new platform, still zero runtime dependencies.
New: the JobCloud platform π¨π
jobs.ch and jobup.ch β
Switzerland's two biggest job boards, every industry, ~50,000 postings β
now sit next to the six devitjobs tech boards. Eight boards, seven countries,
one tool.
They work differently, and the tool is honest about it:
- Search-driven. The inventory can't be mirrored (the API caps at 20
rows/page and 2,000 results per query), so your query runs server-side,
newest first:sdj list "pflegefachfrau" --country jobsch. Without a query
you get the newest postings only. --category itkeeps the all-industry boards in tech β mapped to each
board's own taxonomy (jobs.ch and jobup.ch run different id trees).- No salary data exists on this platform; rows render
βinstead of
pretending. - No native apply. Every posting routes to the company's own ATS, so
direct-applyandapply_to_jobanswerno_native_applywith the real
apply URL β nothing gets silently black-holed.
Board selectors
A country now holds more than one board, so selectors grew up β everything
old keeps working:
sdj list --country ch # ALL Swiss boards: swissdevjobs + jobs.ch + jobup.ch
sdj list --country jobsch # exactly one board
sdj config --countries ch,de # persist any mix of codes and board idsMCP search_jobs mirrors this (country accepts codes and board ids, plus
the new category param), and rows now carry a source field naming their
board.
For board authors
The whole contract for adding a board now lives in
docs/adding-a-board.md:
BoardPort + an anti-corruption layer, registry entry, done. Board requests:
#5.
Also
strip_htmlnow unescapes HTML entities (jobs.ch templates carry literal&)- Cached details rehydrate through their own board's ACL (was hardwired to devitjobs)
- 210 offline tests, 92% branch coverage, CI on Python 3.9 + 3.14
Full Changelog: v0.4.1...v0.5.0
v0.4.1 β syndicated-listing refusals & edge-case hardening
Edge-case hardening, straight from a live audit of all six boards
A full-feed sweep (4,800+ rows) plus in-browser inspection of real postings found things the launch didn't:
- Syndicated listings are now refused by the boards' own flags. Outside Switzerland, 82β100% of listings are paid syndication (
isPartner) redirecting to networks like jobg8, appcast, cv-library, adzuna, indeed. Their pages have no native apply form (verified in the browser), so a native submission would vanish silently.apply_to_job/sdj direct-applynow refuse them withsyndicated_postingand hand your agent the real ATS URL to drive β no host blacklist to chase. - Honeypot documented: the boards embed bait text telling scrapers to email a devitjobs.com address. The bundled skill now warns agents: the API is the only honest surface β never scrape the pages.
- Salary edge cases: single-point "ranges" (419 postings on the UK board alone) render as one value; the docs stop claiming every posting carries a range.
- README: flags moved where they actually render; repo topics added.
Update: /plugin update swissdevjobs@swissdevjobs β or nothing, if you installed after this shipped.
π€ Generated with Claude Code
v0.4.0 β six boards, seven countries
What's new
swissdevjobs-cli is no longer Switzerland-only. The operator of swissdevjobs.ch runs the same platform on five sister boards, and one release now covers all of them:
| Board | Country | Currency | |
|---|---|---|---|
| π¨π | 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 |
That's ~4,700 salary-transparent postings across 7 countries, up from ~240 β searched, deduplicated, and applied to with the same confirmation-gated flow.
Highlights
sdj list --country de --country uk,sdj config --countries ch,de(orall) β every board searched by default- MCP
search_jobsgains acountryparameter; the apply preview now names the board it would submit to - Salaries format in each board's own currency
- The applied-jobs ledger is shared across boards: apply once, the same company+role is hidden everywhere
- Fixed: the warm cache no longer drops
expLevel/hasVisaSponsorship/techCategory, so--visaand--levelfilters now work against cached results
Under the hood
- Full restructure into enforced cosmic-python layering (domain / adapters / service_layer / dto / entrypoints) β import-linter contracts plus an ast-based architecture test fail the build on violations
- Tooling: uv_build, ruff, ty, PostToolUse quality hook, CI on Python 3.9 and 3.14
- 172 offline tests (fakes per port, no mocks), 90% branch-coverage gate, opt-in read-only live lane
- Still zero runtime dependencies β stdlib only, including the MCP server
Install
/plugin marketplace add Stupidoodle/swissdevjobs-cli
/plugin install swissdevjobs@swissdevjobs
or uvx --from git+https://github.com/Stupidoodle/swissdevjobs-cli@v0.4.0 sdj list
Existing plugin installs are pinned to v0.3.0 β update with:
/plugin marketplace update swissdevjobs
/plugin update swissdevjobs@swissdevjobs
After updating, the jobs cache migrates automatically (it's rebuilt on first run); your applications ledger is untouched.
π€ Generated with Claude Code