Skip to content

Releases: Stupidoodle/swissdevjobs-cli

v0.9.2

Choose a tag to compare

@Stupidoodle Stupidoodle released this 28 Aug 11:45
f7b11d6

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

Choose a tag to compare

@Stupidoodle Stupidoodle released this 26 Aug 09:41
a534ed4

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 isPostedOnBehalf is set, hiringCompany is the employer and postedCompany is the agency; isHideHiringEmployerName is honoured.
  • apply_url is 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.
  • Telecommuting now maps to hybrid, not remote β€” the wire lists arrangements an employer offers, which is weaker than a remote-first claim. --remote still matches; --onsite no 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

Choose a tag to compare

@Stupidoodle Stupidoodle released this 26 Aug 05:20

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 --remote all filter normally (remote maps from the wire's Telecommuting arrangement, the only flexible-work value that means where rather than when).
  • --visa, --level, --workload exclude 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-apply refuses with the real posting URL, same as jobs.ch/jobup.ch.
  • New cv/sg.md CV 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

Choose a tag to compare

@Stupidoodle Stupidoodle released this 25 Aug 13:08

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 its jobType (a Contract posting answers to both freelance and temporary β€” 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 contract and workload_from/workload_to (additive, omit-empty). list_boards / sdj boards report 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

Choose a tag to compare

@Stupidoodle Stupidoodle released this 25 Aug 12:55

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-band note explaining why and how to include them again. On MCP, on sdj list --json, and on stderr for the table view.
  • --tech works 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 Python silently dropped all jobs.ch/jobup.ch rows.
  • list_boards / sdj boards report each board's unavailable filter dimensions as data (filters_unavailable, additive key).
  • Salary filters stay fail-closed: --min-salary means "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

Choose a tag to compare

@Stupidoodle Stupidoodle released this 25 Aug 12:12

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_boards MCP tool and sdj boards command: scope,
    currency, salary availability, search-driven, native apply, category
    aliases, and enabled state β€” straight from the registry, per board.
  • The MCP category enum and the CLI --category choices derive from
    the registry
    (known_categories()). A future board registering a
    finance alias appears in every surface without touching prose.
  • The deprecated country param drops its duplicated enum, and
    annotations.title no 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 --json now 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_to plus currency, no
    formatted string.
  • Stable MCP error codes: unknown_selector, job_not_found, and
    internal_error replace leaked Python class names (ValueError used to
    cover two unrelated failures).
  • In-band coverage steering: search results name boards_searched and
    carry a note when 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

Choose a tag to compare

@Stupidoodle Stupidoodle released this 25 Aug 11:40

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_applications declare
    _meta["anthropic/maxResultSizeChars"], keeping max-limit results under
    Claude Code's output warning
  • the skills now mandate --limit on sdj 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 loud invalid_arguments error
    naming the valid values
  • apply_to_job no longer claims destructiveHint: false β€” the spec
    default applies, so clients keep every confirmation layer in front of an
    irreversible submission
  • protocol-version negotiation, tools/list cursor handling, and the
    resources/prompts stubs now match the spec exactly

Steering fixes

  • refusal messages are tool-neutral, reference apply_url instead of
    repeating it, spell the override per entrypoint, and never render
    "visit None" again
  • sdj apply prints the posting's real mode (was hardcoded DIRECT β€”
    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

Choose a tag to compare

@Stupidoodle Stupidoodle released this 25 Aug 11:15

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 it keeps 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-apply and apply_to_job answer no_native_apply with 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 ids

MCP 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_html now unescapes HTML entities (jobs.ch templates carry literal &amp;)
  • 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

Choose a tag to compare

@Stupidoodle Stupidoodle released this 25 Aug 10:22

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-apply now refuse them with syndicated_posting and 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

Choose a tag to compare

@Stupidoodle Stupidoodle released this 25 Aug 09:38

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 (or all) β€” every board searched by default
  • MCP search_jobs gains a country parameter; 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 --visa and --level filters 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