atsfill 0.0.4.dev90__tar.gz → 0.0.4.dev92__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/PKG-INFO +3 -3
  2. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/README.md +2 -2
  3. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/ashby.py +2 -2
  6. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/oracle.py +4 -3
  7. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/pinpoint.py +3 -1
  8. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/workday.py +22 -8
  9. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/wttj.py +82 -5
  10. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/cli.py +2 -0
  11. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/core/accounts.py +4 -2
  12. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/core/adapter.py +3 -1
  13. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/core/commands.py +21 -7
  14. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/core/matching.py +14 -2
  15. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/core/results.py +2 -1
  16. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/registry.toml +2 -1
  17. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/LICENSE +0 -0
  18. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/__init__.py +0 -0
  19. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/__init__.py +0 -0
  20. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/bamboohr.py +0 -0
  21. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/breezy.py +0 -0
  22. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/dayforce.py +0 -0
  23. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/eightfold.py +0 -0
  24. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/greenhouse.py +0 -0
  25. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/hireology.py +0 -0
  26. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/hiringthing.py +0 -0
  27. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/jazzhr.py +0 -0
  28. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/lever.py +0 -0
  29. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/manatal.py +0 -0
  30. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/paycor.py +0 -0
  31. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/paylocity.py +0 -0
  32. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/personio.py +0 -0
  33. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/phenom.py +0 -0
  34. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/recruitee.py +0 -0
  35. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/rippling.py +0 -0
  36. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/smartrecruiters.py +0 -0
  37. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/teamtailor.py +0 -0
  38. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/workable.py +0 -0
  39. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/adapters/zohorecruit.py +0 -0
  40. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/core/__init__.py +0 -0
  41. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/core/attempts.py +0 -0
  42. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/core/browser.py +0 -0
  43. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/core/passwords.py +0 -0
  44. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/core/paths.py +0 -0
  45. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/core/registry.py +0 -0
  46. {atsfill-0.0.4.dev90 → atsfill-0.0.4.dev92}/src/atsfill/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.4.dev90
3
+ Version: 0.0.4.dev92
4
4
  Summary: Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle.
5
5
  Keywords: job-applications,ats,ashby,greenhouse,teamtailor,ai-agents,cli
6
6
  Author: shbernal
@@ -55,7 +55,7 @@ It is built for agents with a shell: every command prints one JSON document and
55
55
  ## Supported hiring systems
56
56
 
57
57
  <!-- badges -->
58
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: mapped 2026-10-20](https://img.shields.io/badge/workday-mapped%202026--10--20-a9b1d6)
58
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: dry run verified 2026-10-24](https://img.shields.io/badge/workday-dry%20run%20verified%202026--10--24-7aa2f7)
59
59
  <!-- /badges -->
60
60
 
61
61
  | System | Account needed | `read` without a browser |
@@ -135,7 +135,7 @@ A dry run or a `send` through an adapter no send was verified on carries a `warn
135
135
 
136
136
  One home can send for more than one user: `--user <name>` gives each their own sessions, double-send guard and `[users.<name>]` section in the config file ([ADR 0015](docs/adr/0015-users-and-accounts.md)).
137
137
  Given an account's email (`atsfill login <adapter> --email`) and a password source (a `[password-commands]` entry such as `rbw get example.com {email}`, or `--password -` on stdin), atsfill signs in by itself, and again whenever a form command finds the session signed out.
138
- Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
138
+ Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--name` where the sign-up asks the user's name) creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
139
139
 
140
140
  ## How it works
141
141
 
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
29
29
  ## Supported hiring systems
30
30
 
31
31
  <!-- badges -->
32
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: mapped 2026-10-20](https://img.shields.io/badge/workday-mapped%202026--10--20-a9b1d6)
32
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![rippling: dry run verified 2026-10-22](https://img.shields.io/badge/rippling-dry%20run%20verified%202026--10--22-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![eightfold: dry run verified 2026-10-22](https://img.shields.io/badge/eightfold-dry%20run%20verified%202026--10--22-7aa2f7) ![pinpoint: dry run verified 2026-10-22](https://img.shields.io/badge/pinpoint-dry%20run%20verified%202026--10--22-7aa2f7) ![hiringthing: dry run verified 2026-10-22](https://img.shields.io/badge/hiringthing-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a) ![workday: dry run verified 2026-10-24](https://img.shields.io/badge/workday-dry%20run%20verified%202026--10--24-7aa2f7)
33
33
  <!-- /badges -->
34
34
 
35
35
  | System | Account needed | `read` without a browser |
@@ -109,7 +109,7 @@ A dry run or a `send` through an adapter no send was verified on carries a `warn
109
109
 
110
110
  One home can send for more than one user: `--user <name>` gives each their own sessions, double-send guard and `[users.<name>]` section in the config file ([ADR 0015](docs/adr/0015-users-and-accounts.md)).
111
111
  Given an account's email (`atsfill login <adapter> --email`) and a password source (a `[password-commands]` entry such as `rbw get example.com {email}`, or `--password -` on stdin), atsfill signs in by itself, and again whenever a form command finds the session signed out.
112
- Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
112
+ Where a system's sign-up allows it, `atsfill login <adapter> --create --email <address>` (with `--name` where the sign-up asks the user's name) creates the account with a password atsfill generates and keeps in the system's secret store, never printed; creating an account is a side effect, never part of `fill` or `send`.
113
113
 
114
114
  ## How it works
115
115
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev90"
3
+ version = "0.0.4.dev92"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev90"
3
+ version = "0.0.4.dev92"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -34,7 +34,7 @@ TYPES = {
34
34
  "String": FieldType.TEXT,
35
35
  "Email": FieldType.TEXT,
36
36
  "LongText": FieldType.TEXT,
37
- "Url": FieldType.TEXT,
37
+ "Url": FieldType.URL,
38
38
  "Date": FieldType.DATE,
39
39
  "Number": FieldType.TEXT,
40
40
  "Phone": FieldType.PHONE,
@@ -146,7 +146,7 @@ class Ashby:
146
146
  entry = page.locator(f'[data-field-path="{field_id}"]')
147
147
  entry.scroll_into_view_if_needed()
148
148
  match fields[field_id].type, answer:
149
- case FieldType.TEXT | FieldType.DATE | FieldType.PHONE, str():
149
+ case FieldType.TEXT | FieldType.URL | FieldType.DATE | FieldType.PHONE, str():
150
150
  box = entry.locator("input, textarea").first
151
151
  box.fill(answer)
152
152
  if fields[field_id].type is FieldType.DATE:
@@ -46,6 +46,7 @@ UPLOAD_TIMEOUT_MS = 30_000
46
46
  CHECKBOX = ["Yes", "No"]
47
47
  KINDS = {
48
48
  "text": FieldType.TEXT,
49
+ "url": FieldType.URL,
49
50
  "phone": FieldType.PHONE,
50
51
  "date": FieldType.DATE,
51
52
  "pills": FieldType.CHOICE,
@@ -136,7 +137,7 @@ _READ = """() => {
136
137
  field.kind = "checks";
137
138
  field.options = pills(".apply-flow-input-checkbox__label");
138
139
  } else if (input?.matches("textarea, input.input-row__control") && !input.disabled) {
139
- field.kind = input.type === "email" ? "email" : "text";
140
+ field.kind = ["email", "url"].includes(input.type) ? input.type : "text";
140
141
  field.value = input.value;
141
142
  field.maxLength = input.maxLength > 0 ? input.maxLength : null;
142
143
  } else if (input?.disabled || !row.querySelector(CONTROL)) {
@@ -212,7 +213,7 @@ _READ_BACK = """(ids) => {
212
213
  const picked = [...row.querySelectorAll("input:checked")]
213
214
  .map(i => text(row.querySelector(`label[for="${i.id}"]`)));
214
215
  out[id] = kind === "radios" ? picked[0] ?? null : picked;
215
- } else if (kind === "text" || kind === "email") {
216
+ } else if (kind === "text" || kind === "email" || kind === "url") {
216
217
  out[id] = input.value;
217
218
  }
218
219
  }
@@ -431,7 +432,7 @@ class Oracle:
431
432
  return
432
433
  row = self._row(page, field_id)
433
434
  match kind, answer:
434
- case "text", str():
435
+ case "text" | "url", str():
435
436
  box = row.locator("input, textarea").first
436
437
  box.fill(answer)
437
438
  if box.get_attribute("role") == "combobox": # an address search, left unpicked
@@ -40,7 +40,7 @@ SUBMIT_TIMEOUT_MS = 90_000
40
40
  YES_NO = ["Yes", "No"]
41
41
  US = "United States" # the country whose address asks for a city and a state
42
42
  JOB_PREFERENCES = "job_preferences"
43
- TEXT_QUESTIONS = {"Shorttext", "Longtext", "Url", "Numberinput"}
43
+ TEXT_QUESTIONS = {"Shorttext", "Longtext", "Url", "Numberinput"} # Url is a `url` field
44
44
  FILE_QUESTIONS = {"Document", "Multipledocuments"}
45
45
  NOT_FIELDS = {"New", "Chatbot", "Stagescarousel"} # components without a field, by last name
46
46
  VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr", "col", "area"}
@@ -459,6 +459,8 @@ def _component(
459
459
  yes_no = props.get("translations", {})
460
460
  if short in TEXT_QUESTIONS:
461
461
  field_type, spec = FieldType.TEXT, _Spec("text", props["name"], root)
462
+ if short == "Url":
463
+ field_type = FieldType.URL
462
464
  if short == "Numberinput":
463
465
  label += " (a number)"
464
466
  elif short == "Boolean":
@@ -56,6 +56,7 @@ PICKED = "[data-automation-id=selectedItem]"
56
56
  DELETE_PICK = "[data-automation-id=DELETE_charm]"
57
57
  SEARCH = "input[data-uxi-widget-type=selectinput]"
58
58
  BACK = "[data-automation-id=backButton]:visible"
59
+ ADDRESS = "address--"
59
60
  PHONE_CODE_ID = "phoneNumber--countryPhoneCode"
60
61
  PHONE_CODE = f'[data-fkit-id="{PHONE_CODE_ID}"]'
61
62
  PHONE_CODES = "/values/phone/countryCodes"
@@ -178,6 +179,7 @@ def _profile_hosts(request: Request, scope: str | None) -> list[str]:
178
179
  class Workday:
179
180
  name = "workday"
180
181
  per_employer = True
182
+ asks_name = False
181
183
  known_gaps = {
182
184
  "experience_entries": Gap.FLOW,
183
185
  "long_wizard": Gap.FLOW,
@@ -234,7 +236,7 @@ class Workday:
234
236
  _email_form(page)
235
237
  _submit(page, "signInSubmitButton", email, password)
236
238
 
237
- def sign_up(self, page: Page, email: str, password: str) -> None:
239
+ def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
238
240
  _email_form(page)
239
241
  page.locator("[data-automation-id=createAccountLink]").click()
240
242
  page.locator("[data-automation-id=verifyPassword]").fill(password)
@@ -315,7 +317,7 @@ class Workday:
315
317
  _until_rendered(page, pending)
316
318
  page.wait_for_timeout(SETTLE_MS)
317
319
  self._unread(page)
318
- self._values |= {r["id"]: r["value"] for r in _page_fields(page)}
320
+ self._values |= {r["id"]: r["value"] for r in _filled(page, answers)}
319
321
  if self._values.get(PHONE_NUMBER) and (code := _phone_code(page)):
320
322
  self._values[PHONE_NUMBER] = f"+{code} {self._values[PHONE_NUMBER]}"
321
323
  _save_and_continue(page)
@@ -323,16 +325,17 @@ class Workday:
323
325
 
324
326
  def _unread(self, page: Page) -> None:
325
327
  """Stops on required fields the read did not list: on My Information, the address of a
326
- country other than the form's; on a later page, one the read could not reach."""
327
- unread = [
328
- r["label"] for r in _page_fields(page) if r["required"] and r["id"] not in self._fields
329
- ]
328
+ country other than the form's, or a follow-up an answer opened (a referral's name); on a
329
+ later page, one the read could not reach."""
330
+ unread = [r for r in _page_fields(page) if r["required"] and r["id"] not in self._fields]
330
331
  if not unread:
331
332
  return
332
- step, labels = _step(page), ", ".join(unread)
333
- if step == MY_INFORMATION:
333
+ step, labels = _step(page), ", ".join(r["label"] for r in unread)
334
+ if step == MY_INFORMATION and any(r["id"].startswith(ADDRESS) for r in unread):
334
335
  detail = f"the country answered shows fields the read did not: {labels}"
335
336
  raise Unsupported(known_gap(self, "other_country", detail))
337
+ if step == MY_INFORMATION:
338
+ raise NotTaken(f"{step} shows fields the read did not: {labels}; an answer opened them")
336
339
  raise NotTaken(f"{step} shows fields the read did not: {labels}; read again")
337
340
 
338
341
  def read_back(self, page: Page) -> dict[str, Answer]:
@@ -530,6 +533,17 @@ def _rendered(page: Page, answers: dict[str, Answer]) -> list[dict[str, Any]]:
530
533
  return raws
531
534
 
532
535
 
536
+ def _filled(page: Page, answers: dict[str, Answer]) -> list[dict[str, Any]]:
537
+ """The page's fields once each answered one shows a value: a picked item can leave the
538
+ page for a moment as it renders again."""
539
+ for _ in range(RENDER_TRIES):
540
+ raws = _page_fields(page)
541
+ if all(r["value"] not in (None, "", []) for r in raws if r["id"] in answers):
542
+ break
543
+ page.wait_for_timeout(SETTLE_MS)
544
+ return raws
545
+
546
+
533
547
  def _field(raw: dict[str, Any]) -> Field | None:
534
548
  kind, options = KINDS.get(raw["kind"]), raw["options"]
535
549
  if raw["kind"] == "box":
@@ -1,8 +1,9 @@
1
1
  """Welcome to the Jungle (`welcometothejungle.com` jobs and their `easyapply.jobs` links), as
2
2
  docs/adapters/wttj.md observes.
3
3
 
4
- Applying needs an account: the form opens in a modal, prefilled from the account profile. Nothing is
5
- sent before submit, so the read-back is the form's inputs and the CV's shown name.
4
+ Applying needs an account, which atsfill can sign in to and create: the form opens in a modal,
5
+ prefilled from the account profile. Nothing is sent before submit, so the read-back is the form's
6
+ inputs and the CV's shown name.
6
7
  """
7
8
 
8
9
  import re
@@ -11,14 +12,18 @@ from typing import Any
11
12
  from urllib.parse import urlsplit
12
13
 
13
14
  from atsfill.core import browser, matching
14
- from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported
15
- from atsfill.core.browser import Locator, Page, Request, Timeout
15
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported
16
+ from atsfill.core.browser import Locator, Page, Request, Response, Timeout
16
17
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
17
18
 
18
19
  HOST = "www.welcometothejungle.com"
19
20
  JOB = re.compile(r"^/(?:[a-z]{2}/)?companies/([^/]+)/jobs/([^/?#]+)")
20
21
  FORM = "form:has([data-testid=apply-form-submit])"
21
22
  ME = "https://api.welcometothejungle.com/api/v2/users/me"
23
+ SIGN_UP = f"https://{HOST}/en/authenticate/signup"
24
+ ACCOUNT_TIMEOUT_MS = 20_000
25
+ ACCOUNT_TIMEOUT_S = 20
26
+ ACCOUNT_POLL_MS = 250
22
27
  FORM_TIMEOUT_MS = 20_000
23
28
  SIGNED_IN_TIMEOUT_S = 10
24
29
  SUBMIT_TIMEOUT_MS = 90_000
@@ -58,8 +63,12 @@ _READ_BACK = """(form, ids) => Object.fromEntries(ids.map(id => {
58
63
  class WelcomeToTheJungle:
59
64
  name = "wttj"
60
65
  per_employer = False
66
+ asks_name = True # the profile's first name
61
67
  known_gaps: dict[str, Gap] = {}
62
- side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
68
+ side_effects = [
69
+ SideEffect("sign_up", "hiring_system", "login --create creates the account"),
70
+ SideEffect("submit", "employer", "the application reaches the employer"),
71
+ ]
63
72
 
64
73
  def __init__(self) -> None:
65
74
  self._fields: dict[str, Field] = {}
@@ -85,6 +94,17 @@ class WelcomeToTheJungle:
85
94
  """`users/me` answers 200 for an account, 204 for a visitor."""
86
95
  return request.get(ME).status == 200
87
96
 
97
+ def sign_in(self, page: Page, email: str, password: str) -> None:
98
+ _account_form(page, "sign-in-form", {"email": email, "password": password}, "/sessions")
99
+
100
+ def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
101
+ """Creates the account, signed in, then waits for its confirmation link: clicking it
102
+ signs every session out."""
103
+ page.goto(SIGN_UP)
104
+ values = {"firstname": name or "", "email": email, "password": password}
105
+ _account_form(page, "sign-up-form", values, "/registrations")
106
+ raise Blocked("email_link: the sign-up sent a confirmation link")
107
+
88
108
  def read_form(self, page: Page) -> list[Field]:
89
109
  """Opens the apply modal; a job that applies on another site has no form here."""
90
110
  if not JOB.match(urlsplit(page.url).path):
@@ -168,6 +188,63 @@ class WelcomeToTheJungle:
168
188
  raise RuntimeError(f"POST {response.url} answered {response.status}")
169
189
 
170
190
 
191
+ def _account_form(page: Page, form: str, values: dict[str, str], posts: str) -> None:
192
+ """Fills the sign-in or sign-up form, submits it and returns once the system took it; raises
193
+ what the system answered, or what the form says under a field it refused without posting."""
194
+ try:
195
+ page.locator(f"[data-testid={form}-submit-button]").wait_for(timeout=ACCOUNT_TIMEOUT_MS)
196
+ except Timeout:
197
+ if "too many requests" in page.locator("body").inner_text().lower():
198
+ reason = f"rate_limited: {urlsplit(page.url).path} answers too many requests"
199
+ raise Blocked(reason) from None
200
+ raise Unsupported(f"unhandled_rendering: no {form} on {page.url}") from None
201
+ for field, value in values.items():
202
+ page.locator(f"[data-testid={form}-{field}-input]").fill(value)
203
+ # posted before the page has its CSRF token, the form is answered 403 and says nothing
204
+ page.wait_for_function(
205
+ "() => document.cookie.includes('csrf-token=')", timeout=ACCOUNT_TIMEOUT_MS
206
+ )
207
+ answered: list[Response] = []
208
+
209
+ def on_response(response: Response) -> None:
210
+ if response.request.method == "POST" and urlsplit(response.url).path.endswith(posts):
211
+ answered.append(response)
212
+
213
+ page.on("response", on_response)
214
+ on_form = page.url
215
+ page.locator(f"[data-testid={form}-submit-button]").click()
216
+ said = page.locator(f"[data-testid^={form}-][data-testid$=-error]")
217
+ deadline = time.monotonic() + ACCOUNT_TIMEOUT_S
218
+ try:
219
+ while time.monotonic() < deadline:
220
+ if answered:
221
+ _account_answer(answered[0])
222
+ # the page leaves the form on its own: a navigation before then is aborted
223
+ page.wait_for_url(lambda url: url != on_form, timeout=ACCOUNT_TIMEOUT_MS)
224
+ return
225
+ if said.count():
226
+ field = said.first.get_attribute("data-testid") or ""
227
+ text = said.first.inner_text().strip()
228
+ if field.endswith("-password-error"):
229
+ raise Refused(f"password_rule: {text}")
230
+ raise Unsupported(f"unhandled_rendering: the form says {text!r}")
231
+ page.wait_for_timeout(ACCOUNT_POLL_MS)
232
+ finally:
233
+ page.remove_listener("response", on_response)
234
+ raise Unsupported("unhandled_rendering: the form answered nothing")
235
+
236
+
237
+ def _account_answer(response: Response) -> None:
238
+ match response.status:
239
+ case 200 | 201:
240
+ return
241
+ case 401:
242
+ raise Refused("wrong_password")
243
+ case 422 if "email" in response.json().get("errors", {}):
244
+ raise Refused("email_in_use")
245
+ raise RuntimeError(f"POST {urlsplit(response.url).path} answered {response.status}")
246
+
247
+
171
248
  def _suggest(page: Page, field: Field, box: Locator, answer: str) -> None:
172
249
  """Retypes the answer key by key, which loads the suggestions, and picks the first that
173
250
  matches it, never "current position" (the item with an icon, which geolocates). Typed too
@@ -105,6 +105,7 @@ def parser() -> argparse.ArgumentParser:
105
105
  action="store_true",
106
106
  help="create the account with --email and a password atsfill generates and stores",
107
107
  )
108
+ login.add_argument("--name", help="the user's name, for a sign-up that asks it")
108
109
  for name in ("read", "fill", "send", "status", "login", "logout"):
109
110
  sub.choices[name].add_argument(
110
111
  "-v", "--verbose", action="store_true", help="the whole result, every key, indented"
@@ -170,6 +171,7 @@ def run(args: argparse.Namespace) -> int:
170
171
  password=password,
171
172
  window=args.window,
172
173
  create=args.create,
174
+ name=args.name,
173
175
  **where,
174
176
  )
175
177
  except ValueError as e:
@@ -54,14 +54,16 @@ def sign_in(
54
54
  password: str,
55
55
  session: Path,
56
56
  create: bool = False,
57
+ name: str | None = None,
57
58
  ) -> None:
58
- """Signs in, or creates the account, from the system's login page and stores the session.
59
+ """Signs in, or creates the account (with the user's `name` where it asks one), from the
60
+ system's login page and stores the session.
59
61
  Raises what the adapter raises, without the password in it."""
60
62
  page.goto(adapter.login_url(scope))
61
63
  try:
62
64
  if create:
63
65
  assert isinstance(adapter, CreatesAccount)
64
- adapter.sign_up(page, address, password)
66
+ adapter.sign_up(page, address, password, name)
65
67
  else:
66
68
  adapter.sign_in(page, address, password)
67
69
  except Exception as e:
@@ -128,7 +128,9 @@ class CreatesAccount(SignsIn, Protocol):
128
128
  """An adapter atsfill can create an account through, where the system's sign-up allows it.
129
129
  Account creation is a side effect it declares (ADR 0008)."""
130
130
 
131
- def sign_up(self, page: Page, email: str, password: str) -> None:
131
+ asks_name: bool # the sign-up asks the user's name
132
+
133
+ def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
132
134
  """Creates the account from the page `login_url` loaded, signed in once it returns.
133
135
  Raises as `sign_in` does; `Refused` names the rule a `password_rule` page showed."""
134
136
  ...
@@ -425,13 +425,16 @@ def login(
425
425
  password: str | None = None,
426
426
  window: bool = False,
427
427
  create: bool = False,
428
+ name: str | None = None,
428
429
  data_dir: Path | None = None,
429
430
  ) -> SessionReport:
430
431
  """Signs in with the account's password source, headless, and stores the session; without
431
432
  a source, or with `window`, opens a window on the system's login page for a person.
432
433
 
433
434
  `email` is remembered for the account; `password` is used for this sign-in only. `create`
434
- creates the account with a password atsfill generates and stores first (a side effect)."""
435
+ creates the account with a password atsfill generates and stores first (a side effect),
436
+ giving `name` where the sign-up asks it; with a password already stored, the account may
437
+ exist, so it signs in with that password and keeps the account's email instead."""
435
438
  account, data = _account(adapter), paths.data_dir(data_dir)
436
439
  scope = _scope(account, url)
437
440
  mine = paths.user_dir(data, user)
@@ -439,11 +442,18 @@ def login(
439
442
  report = SessionReport(user, adapter, scope, "missing", session)
440
443
  if create and not email:
441
444
  raise ValueError("creating an account needs --email")
442
- if email:
445
+ try:
446
+ kept = create and passwords.stored(mine, adapter, scope) is not None
447
+ except passwords.PasswordError as e:
448
+ report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
449
+ return report
450
+ if email and not kept:
443
451
  accounts.remember(mine, adapter, scope, email)
444
- if create:
452
+ if create and not kept:
445
453
  assert email
446
- return _create(report, account, url, mine, email, data)
454
+ return _create(report, account, url, mine, (email, name), data)
455
+ if kept:
456
+ report.password = "kept"
447
457
  if password and not (isinstance(account, SignsIn) and accounts.email(mine, adapter, scope)):
448
458
  raise ValueError(f"signing in to {adapter} with a password needs --email")
449
459
  try:
@@ -473,7 +483,7 @@ def _create(
473
483
  account: NeedsAccount,
474
484
  url: str | None,
475
485
  mine: Path,
476
- email: str,
486
+ who: tuple[str, str | None],
477
487
  data: Path,
478
488
  ) -> SessionReport:
479
489
  """Creates the account: the generated password is stored before the click, and deleted
@@ -482,13 +492,16 @@ def _create(
482
492
  report.status = Status.UNSUPPORTED
483
493
  report.reason = f"no_sign_up: atsfill cannot create a {account.name} account"
484
494
  return report
495
+ email, name = who
496
+ if account.asks_name and not name:
497
+ raise ValueError(f"creating a {account.name} account needs --name")
485
498
  password = passwords.generate()
486
499
  try:
487
500
  passwords.store(mine, account.name, report.scope, password)
488
501
  except passwords.PasswordError as e:
489
502
  report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
490
503
  return report
491
- result = _headless(data, account, report.scope, (email, password), report.path, create=True)
504
+ result = _headless(data, account, report.scope, (email, password), report.path, True, name)
492
505
  command = login_command(account.name, report.user, url)
493
506
  report.status, report.reason = result.status, _email_link(result.reason, email, command)
494
507
  report.password = "stored"
@@ -507,6 +520,7 @@ def _headless(
507
520
  credentials: Credentials,
508
521
  session: Path,
509
522
  create: bool = False,
523
+ name: str | None = None,
510
524
  ) -> Result:
511
525
  """Signs in, or creates the account, through the adapter: `ready` once the session is
512
526
  stored."""
@@ -516,7 +530,7 @@ def _headless(
516
530
  def run(_: list[str]) -> Result:
517
531
  result = Result(Status.READY, url, account.name)
518
532
  with open_page(data, None) as page, _stops(result):
519
- accounts.sign_in(page, account, scope, *credentials, session, create)
533
+ accounts.sign_in(page, account, scope, *credentials, session, create, name)
520
534
  return result
521
535
 
522
536
  return _in_budget(url, run)
@@ -3,6 +3,7 @@
3
3
  import re
4
4
  import unicodedata
5
5
  from pathlib import Path, PurePath
6
+ from urllib.parse import urlsplit
6
7
 
7
8
  from atsfill.core.results import Answer, Check, Field, FieldType
8
9
 
@@ -25,6 +26,11 @@ def _picked_from(answer: str, suggestion: str) -> bool:
25
26
  return all(any(p == wanted.strip() for p in parts) for wanted in answer.split(","))
26
27
 
27
28
 
29
+ def _absolute_url(value: str) -> bool:
30
+ parts = urlsplit(_norm(value))
31
+ return parts.scheme in ("http", "https") and bool(parts.netloc)
32
+
33
+
28
34
  def matches(field: Field, answer: Answer, read_back: Answer | None) -> bool:
29
35
  if read_back is None:
30
36
  return False
@@ -36,7 +42,7 @@ def matches(field: Field, answer: Answer, read_back: Answer | None) -> bool:
36
42
  if not isinstance(answer, str) or not isinstance(read_back, str):
37
43
  return False
38
44
  match field.type:
39
- case FieldType.TEXT:
45
+ case FieldType.TEXT | FieldType.URL:
40
46
  return _norm(answer) == _norm(read_back)
41
47
  case FieldType.DATE:
42
48
  return answer == read_back
@@ -74,8 +80,14 @@ def _answer_problem(field: Field, answer: object) -> str | None:
74
80
  if field.type in (FieldType.CHOICE, FieldType.MULTI_CHOICE) and not _in_options(field, values):
75
81
  return f"{answer!r} is not one of {field.options}"
76
82
  length = len(_norm(values[0]))
77
- if field.type is FieldType.TEXT and field.max_length and length > field.max_length:
83
+ if (
84
+ field.type in (FieldType.TEXT, FieldType.URL)
85
+ and field.max_length
86
+ and length > field.max_length
87
+ ):
78
88
  return f"{length} characters, over the limit of {field.max_length}"
89
+ if field.type is FieldType.URL and not _absolute_url(values[0]):
90
+ return "a url answer is an absolute http(s) URL"
79
91
  if field.type is FieldType.DATE and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", values[0]):
80
92
  return "a date answer is YYYY-MM-DD"
81
93
  if field.type is FieldType.FILE:
@@ -20,6 +20,7 @@ class FieldType(StrEnum):
20
20
  TYPEAHEAD = "typeahead"
21
21
  FILE = "file"
22
22
  PHONE = "phone"
23
+ URL = "url" # the system declares it a URL; answered as an absolute http(s) URL
23
24
 
24
25
 
25
26
  type Answer = str | list[str]
@@ -75,7 +76,7 @@ class SessionReport:
75
76
  path: Path
76
77
  status: str | None = None # a login's outcome: signed_in | refused | blocked | unsupported
77
78
  reason: str | None = None # why a login stored nothing, `code` or `code: detail`
78
- password: str | None = None # after `--create`, the generated one: stored | deleted
79
+ password: str | None = None # after `--create`: stored | deleted, or kept
79
80
 
80
81
 
81
82
  @dataclass
@@ -53,7 +53,7 @@ account = "to_send"
53
53
  hosting = ["hosted"]
54
54
  adapter = "wttj"
55
55
  send_verified = { date = 2026-10-20, version = "0.0.3", by = "shbernal", commit = "9bc6d84" }
56
- dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "adapter_gate", commit = "ad9f478" }
56
+ dry_run_verified = { date = 2026-10-24, version = "0.0.3", by = "adapter_gate", commit = "90c58c3" }
57
57
 
58
58
  [workday]
59
59
  kind = "ats"
@@ -62,6 +62,7 @@ account = "to_read"
62
62
  hosting = ["hosted"]
63
63
  adapter = "workday"
64
64
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
65
+ dry_run_verified = { date = 2026-10-24, version = "0.0.3", by = "adapter_gate", commit = "fdb237a" }
65
66
 
66
67
  [trudvsem]
67
68
  kind = "board"
File without changes