katze 0.1.1__py3-none-any.whl
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.
- katze/__init__.py +4 -0
- katze/cli.py +245 -0
- katze/client.py +97 -0
- katze/config.py +224 -0
- katze/fill.py +915 -0
- katze/letter.py +393 -0
- katze/pickers.py +82 -0
- katze/questions.py +621 -0
- katze/screens.py +228 -0
- katze/shell.py +234 -0
- katze/tui.py +1008 -0
- katze-0.1.1.dist-info/METADATA +259 -0
- katze-0.1.1.dist-info/RECORD +16 -0
- katze-0.1.1.dist-info/WHEEL +5 -0
- katze-0.1.1.dist-info/entry_points.txt +2 -0
- katze-0.1.1.dist-info/top_level.txt +1 -0
katze/__init__.py
ADDED
katze/cli.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from katze import config as cfg
|
|
13
|
+
from katze import shell
|
|
14
|
+
from katze.client import ApiError, JobKatzeClient
|
|
15
|
+
from katze.fill import (
|
|
16
|
+
create_browser_context,
|
|
17
|
+
detect_ats_source,
|
|
18
|
+
extract_dom_questions,
|
|
19
|
+
open_and_fill_job,
|
|
20
|
+
)
|
|
21
|
+
from katze.letter import (
|
|
22
|
+
compile_typst_pdf,
|
|
23
|
+
extract_resume_text,
|
|
24
|
+
generate_cover_letter,
|
|
25
|
+
write_and_compile_letter,
|
|
26
|
+
)
|
|
27
|
+
from katze.pickers import browser_login, pick_pdf_via_finder
|
|
28
|
+
from katze.questions import (
|
|
29
|
+
DIRECT_ATS_SOURCES,
|
|
30
|
+
harvest_jobs_questions,
|
|
31
|
+
save_questions,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
BRAND_SCHWARZ = "#26211b"
|
|
35
|
+
BRAND_ROT = "#c73e3e"
|
|
36
|
+
BRAND_GOLD = "#d99a06"
|
|
37
|
+
BRAND_GREEN = "#3d7a52"
|
|
38
|
+
BRAND_PAW = "#b98a5e"
|
|
39
|
+
BRAND_MUTED = "#877a68"
|
|
40
|
+
FLAG_STRIPE = f"[{BRAND_SCHWARZ}]━━━━━━━━━[/][{BRAND_ROT}]━━━━━━━━━[/][{BRAND_GOLD}]━━━━━━━━━[/]"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _env_badge(api_url: str) -> str:
|
|
44
|
+
url_low = (api_url or "").lower()
|
|
45
|
+
if "localhost" in url_low or "127.0.0.1" in url_low:
|
|
46
|
+
return f"[{BRAND_GOLD}]● Local Dev[/]"
|
|
47
|
+
return f"[{BRAND_GREEN}]● Production Cloud[/]"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
app = typer.Typer(help="JobKatze Auto-Apply TUI (katze)", invoke_without_command=True)
|
|
51
|
+
console = Console()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.callback(invoke_without_command=True)
|
|
55
|
+
def main(ctx: typer.Context):
|
|
56
|
+
if ctx.invoked_subcommand is None:
|
|
57
|
+
cfg.ensure_initialized()
|
|
58
|
+
_launch()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _load_pending_tabs(path: Path) -> list[dict]:
|
|
62
|
+
if not path.exists():
|
|
63
|
+
return []
|
|
64
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _save_pending_tabs(path: Path, items: list[dict]) -> None:
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
path.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _select_jobs(client: JobKatzeClient, from_source: str, source: str, limit: int, job_id: str = "") -> list[dict]:
|
|
73
|
+
if job_id:
|
|
74
|
+
return [client.get_job(job_id)]
|
|
75
|
+
if from_source == "match":
|
|
76
|
+
effective_source = source or DIRECT_ATS_SOURCES
|
|
77
|
+
matches = client.match_jobs(source=effective_source, limit=limit)
|
|
78
|
+
if matches:
|
|
79
|
+
client.set_applications([j["id"] for j in matches], "saved")
|
|
80
|
+
return matches[:limit]
|
|
81
|
+
apps = client.list_applications()
|
|
82
|
+
saved = [j for j in apps if j.get("application_status") == "saved"]
|
|
83
|
+
if source:
|
|
84
|
+
sources = {s.strip().lower() for s in source.split(",") if s.strip()}
|
|
85
|
+
saved = [j for j in saved if j.get("source", "").lower() in sources]
|
|
86
|
+
saved.sort(
|
|
87
|
+
key=lambda j: (
|
|
88
|
+
j.get("match_score") is not None,
|
|
89
|
+
j.get("match_score", 0),
|
|
90
|
+
j.get("application_updated", ""),
|
|
91
|
+
),
|
|
92
|
+
reverse=True,
|
|
93
|
+
)
|
|
94
|
+
return saved[:limit]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _job_from_url(client: JobKatzeClient, target_url: str) -> dict:
|
|
98
|
+
try:
|
|
99
|
+
imported = client.import_job(target_url)
|
|
100
|
+
if imported.get("job"):
|
|
101
|
+
return imported["job"]
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
src = detect_ats_source(target_url) or "direct"
|
|
105
|
+
comp = ""
|
|
106
|
+
m_slug = re.search(r"https?://(?:jobs\.ashbyhq\.com|boards\.greenhouse\.io|jobs\.lever\.co)/([^/?#]+)", target_url)
|
|
107
|
+
if m_slug:
|
|
108
|
+
comp = m_slug.group(1).replace("-", " ").capitalize()
|
|
109
|
+
else:
|
|
110
|
+
m_sub = re.search(r"https?://([^.]+)\.(?:personio\.(?:de|com)|recruitee\.com)", target_url)
|
|
111
|
+
if m_sub:
|
|
112
|
+
comp = m_sub.group(1).replace("-", " ").capitalize()
|
|
113
|
+
jid = f"custom_{hashlib.sha256(target_url.encode()).hexdigest()[:10]}"
|
|
114
|
+
return {
|
|
115
|
+
"id": jid,
|
|
116
|
+
"url": target_url,
|
|
117
|
+
"company": comp or "Direct ATS Job",
|
|
118
|
+
"title": "Position",
|
|
119
|
+
"source": src,
|
|
120
|
+
"active": True,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _apply_core(
|
|
125
|
+
client: JobKatzeClient,
|
|
126
|
+
*,
|
|
127
|
+
target_url: str = "",
|
|
128
|
+
from_source: str = "saved",
|
|
129
|
+
source: str = "",
|
|
130
|
+
limit: int = 5,
|
|
131
|
+
job_id: str = "",
|
|
132
|
+
jobs: list[dict] | None = None,
|
|
133
|
+
regen: bool = False,
|
|
134
|
+
on_progress: Callable[[dict], None] | None = None,
|
|
135
|
+
) -> str:
|
|
136
|
+
def _notify(payload: dict) -> None:
|
|
137
|
+
if on_progress:
|
|
138
|
+
on_progress(payload)
|
|
139
|
+
|
|
140
|
+
base_dir = cfg.get_config_dir()
|
|
141
|
+
profile = cfg.load_profile()
|
|
142
|
+
resume_path = cfg.resolve_resume_path()
|
|
143
|
+
typst_path = base_dir / cfg.TYPST_FILE
|
|
144
|
+
questions_path = base_dir / cfg.QUESTIONS_FILE
|
|
145
|
+
pending_path = base_dir / cfg.PENDING_TABS_FILE
|
|
146
|
+
|
|
147
|
+
explicit_selection = jobs is not None or bool(job_id) or bool(target_url)
|
|
148
|
+
if jobs is not None:
|
|
149
|
+
target_jobs = list(jobs)
|
|
150
|
+
elif target_url:
|
|
151
|
+
target_jobs = [_job_from_url(client, target_url)]
|
|
152
|
+
else:
|
|
153
|
+
try:
|
|
154
|
+
target_jobs = _select_jobs(client, from_source=from_source, source=source, limit=limit, job_id=job_id)
|
|
155
|
+
except ApiError as exc:
|
|
156
|
+
return f"Failed to load jobs: {exc.message}"
|
|
157
|
+
|
|
158
|
+
if not target_jobs:
|
|
159
|
+
return "No eligible jobs found to apply. Use /match or save jobs on JobKatze."
|
|
160
|
+
|
|
161
|
+
_notify({"phase": "connecting to Chrome", "step": 0, "total": len(target_jobs), "label": "Chrome"})
|
|
162
|
+
context = create_browser_context(base_dir / "chrome-profile")
|
|
163
|
+
|
|
164
|
+
resume_text = extract_resume_text(resume_path) if resume_path.exists() else ""
|
|
165
|
+
opened_sessions = []
|
|
166
|
+
skipped_inactive = 0
|
|
167
|
+
pending_by_id = {item["id"]: item for item in _load_pending_tabs(pending_path)}
|
|
168
|
+
|
|
169
|
+
for idx, job in enumerate(target_jobs, 1):
|
|
170
|
+
jid = job["id"]
|
|
171
|
+
if not explicit_selection and job.get("active") is False:
|
|
172
|
+
skipped_inactive += 1
|
|
173
|
+
continue
|
|
174
|
+
try:
|
|
175
|
+
full_job = client.get_job(jid) or job
|
|
176
|
+
except Exception:
|
|
177
|
+
full_job = job
|
|
178
|
+
if not full_job.get("id"):
|
|
179
|
+
full_job["id"] = jid
|
|
180
|
+
|
|
181
|
+
label = f"{full_job.get('company', '')} - {full_job.get('title', '')}".strip(" -") or str(jid)
|
|
182
|
+
_notify({"phase": "writing cover letter", "step": idx, "total": len(target_jobs), "label": label, "job_id": jid})
|
|
183
|
+
|
|
184
|
+
_, txt_path, pdf_path = write_and_compile_letter(
|
|
185
|
+
base_dir,
|
|
186
|
+
full_job,
|
|
187
|
+
resume_text,
|
|
188
|
+
profile,
|
|
189
|
+
typst_path,
|
|
190
|
+
regen=regen,
|
|
191
|
+
generate_fn=generate_cover_letter,
|
|
192
|
+
compile_fn=compile_typst_pdf,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
cl_text = txt_path.read_text(encoding="utf-8") if txt_path.exists() else ""
|
|
196
|
+
_notify({"phase": "opening & filling tab", "step": idx, "total": len(target_jobs), "label": label, "job_id": jid})
|
|
197
|
+
page, unfilled = open_and_fill_job(
|
|
198
|
+
context,
|
|
199
|
+
full_job,
|
|
200
|
+
profile,
|
|
201
|
+
resume_path=resume_path,
|
|
202
|
+
cover_letter_pdf=pdf_path,
|
|
203
|
+
cover_letter_txt=cl_text,
|
|
204
|
+
)
|
|
205
|
+
http_qs = harvest_jobs_questions([full_job], max_workers=1)
|
|
206
|
+
dom_qs = extract_dom_questions(page, full_job)
|
|
207
|
+
save_questions(http_qs or dom_qs, questions_path)
|
|
208
|
+
|
|
209
|
+
pending_by_id[jid] = {
|
|
210
|
+
"id": jid,
|
|
211
|
+
"company": full_job.get("company", ""),
|
|
212
|
+
"title": full_job.get("title", ""),
|
|
213
|
+
"source": full_job.get("source", ""),
|
|
214
|
+
"url": full_job.get("url", ""),
|
|
215
|
+
"unfilled": unfilled,
|
|
216
|
+
}
|
|
217
|
+
opened_sessions.append((full_job, page))
|
|
218
|
+
_notify(
|
|
219
|
+
{
|
|
220
|
+
"phase": "done_one",
|
|
221
|
+
"step": idx,
|
|
222
|
+
"total": len(target_jobs),
|
|
223
|
+
"label": label,
|
|
224
|
+
"job_id": jid,
|
|
225
|
+
"unfilled": len(unfilled),
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
_save_pending_tabs(pending_path, list(pending_by_id.values()))
|
|
230
|
+
if not opened_sessions and skipped_inactive:
|
|
231
|
+
return f"No active jobs to open (skipped {skipped_inactive} inactive job(s))."
|
|
232
|
+
ready_count = sum(1 for j, _ in opened_sessions if not pending_by_id.get(j.get("id", ""), {}).get("unfilled"))
|
|
233
|
+
needs_count = len(opened_sessions) - ready_count
|
|
234
|
+
return f"Opened {len(opened_sessions)} tab(s) in Chrome ({ready_count} READY, {needs_count} need review). Run /confirm when submitted."
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
from katze.tui import (
|
|
238
|
+
IN_TUI_HANDLERS,
|
|
239
|
+
_banner,
|
|
240
|
+
_handlers,
|
|
241
|
+
_launch,
|
|
242
|
+
_refresh,
|
|
243
|
+
_status_line,
|
|
244
|
+
render_screen,
|
|
245
|
+
)
|
katze/client.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import httpx
|
|
6
|
+
from katze.config import AppConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApiError(Exception):
|
|
10
|
+
def __init__(self, status_code: int, message: str):
|
|
11
|
+
super().__init__(f"API error {status_code}: {message}")
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
self.message = message
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JobKatzeClient:
|
|
17
|
+
def __init__(self, config: AppConfig):
|
|
18
|
+
self.base_url = config.api_url.rstrip("/")
|
|
19
|
+
self.token = config.token
|
|
20
|
+
|
|
21
|
+
def _headers(self) -> dict[str, str]:
|
|
22
|
+
headers = {"Content-Type": "application/json"}
|
|
23
|
+
if self.token:
|
|
24
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
25
|
+
return headers
|
|
26
|
+
|
|
27
|
+
def _request(self, method: str, path: str, json: dict | None = None, params: dict | None = None) -> dict:
|
|
28
|
+
url = f"{self.base_url}{path}"
|
|
29
|
+
with httpx.Client(timeout=30.0) as client:
|
|
30
|
+
try:
|
|
31
|
+
resp = client.request(method, url, json=json, params=params, headers=self._headers())
|
|
32
|
+
except httpx.TransportError:
|
|
33
|
+
resp = client.request(method, url, json=json, params=params, headers=self._headers())
|
|
34
|
+
if resp.status_code >= 400:
|
|
35
|
+
try:
|
|
36
|
+
err_body = resp.json()
|
|
37
|
+
msg = err_body.get("error", resp.text)
|
|
38
|
+
except Exception:
|
|
39
|
+
msg = resp.text
|
|
40
|
+
raise ApiError(resp.status_code, msg)
|
|
41
|
+
return resp.json()
|
|
42
|
+
|
|
43
|
+
def login(self, email: str, password: str) -> str:
|
|
44
|
+
data = self._request("POST", "/auth/login", json={"email": email, "password": password})
|
|
45
|
+
token = data.get("token", "")
|
|
46
|
+
self.token = token
|
|
47
|
+
return token
|
|
48
|
+
|
|
49
|
+
def me(self) -> dict:
|
|
50
|
+
return self._request("GET", "/auth/me")
|
|
51
|
+
|
|
52
|
+
def get_job(self, job_id: str) -> dict:
|
|
53
|
+
return self._request("GET", f"/jobs/{job_id}")
|
|
54
|
+
|
|
55
|
+
def list_applications(self) -> list[dict]:
|
|
56
|
+
data = self._request("GET", "/applications")
|
|
57
|
+
return data.get("jobs", [])
|
|
58
|
+
|
|
59
|
+
def set_application(self, job_id: str, status: str) -> dict:
|
|
60
|
+
return self._request("POST", "/applications", json={"job_id": job_id, "status": status})
|
|
61
|
+
|
|
62
|
+
def set_applications(self, job_ids: list[str], status: str) -> dict:
|
|
63
|
+
ids = list(dict.fromkeys(str(j) for j in job_ids if j))
|
|
64
|
+
if not ids:
|
|
65
|
+
return {"job_ids": [], "status": status, "count": 0}
|
|
66
|
+
try:
|
|
67
|
+
return self._request("POST", "/applications", json={"job_ids": ids, "status": status})
|
|
68
|
+
except ApiError as exc:
|
|
69
|
+
if exc.status_code == 400 and "job_id" in exc.message:
|
|
70
|
+
for jid in ids:
|
|
71
|
+
self.set_application(jid, status)
|
|
72
|
+
return {"job_ids": ids, "status": status, "count": len(ids)}
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
def match_jobs(self, source: str = "", limit: int = 50) -> list[dict]:
|
|
76
|
+
params = {"limit": str(limit)}
|
|
77
|
+
if source:
|
|
78
|
+
params["source"] = source
|
|
79
|
+
data = self._request("GET", "/match", params=params)
|
|
80
|
+
return data.get("jobs", [])
|
|
81
|
+
|
|
82
|
+
def import_job(self, url: str, company: str = "", title: str = "") -> dict:
|
|
83
|
+
return self._request(
|
|
84
|
+
"POST",
|
|
85
|
+
"/jobs/import",
|
|
86
|
+
json={"url": url, "company": company, "title": title},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def upload_resume(self, path: Path) -> dict:
|
|
90
|
+
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
91
|
+
return self._request(
|
|
92
|
+
"POST",
|
|
93
|
+
"/profile/resume",
|
|
94
|
+
json={"filename": path.name, "content_base64": encoded},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
katze/config.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import asdict, dataclass, fields
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "jobkatze"
|
|
10
|
+
CONFIG_FILE = "config.toml"
|
|
11
|
+
PROFILE_FILE = "profile.yaml"
|
|
12
|
+
RESUME_FILE = "resume.pdf"
|
|
13
|
+
TYPST_FILE = "letter.typ"
|
|
14
|
+
QUESTIONS_FILE = "questions.jsonl"
|
|
15
|
+
PENDING_TABS_FILE = "pending_tabs.json"
|
|
16
|
+
PROD_API_URL = "https://func-jobkatze-6cdva9lr.azurewebsites.net/api"
|
|
17
|
+
LOCAL_API_URL = "http://localhost:7071/api"
|
|
18
|
+
PROD_FRONTEND_URL = "https://jobkatze.works"
|
|
19
|
+
LOCAL_FRONTEND_URL = "http://localhost:3000"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def frontend_url_for(api_url: str) -> str:
|
|
23
|
+
local = "localhost" in api_url or "127.0.0.1" in api_url
|
|
24
|
+
return LOCAL_FRONTEND_URL if local else PROD_FRONTEND_URL
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AppConfig:
|
|
29
|
+
api_url: str = PROD_API_URL
|
|
30
|
+
token: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CandidateProfile:
|
|
35
|
+
first_name: str = ""
|
|
36
|
+
last_name: str = ""
|
|
37
|
+
email: str = ""
|
|
38
|
+
phone: str = ""
|
|
39
|
+
city: str = ""
|
|
40
|
+
country: str = "Germany"
|
|
41
|
+
linkedin: str = ""
|
|
42
|
+
github: str = ""
|
|
43
|
+
website: str = ""
|
|
44
|
+
eu_work_permit: bool = True
|
|
45
|
+
requires_sponsorship: bool = False
|
|
46
|
+
nationality: str = ""
|
|
47
|
+
work_permit_type: str = ""
|
|
48
|
+
salary_expectation_eur: int = 0
|
|
49
|
+
earliest_start: str = ""
|
|
50
|
+
notice_period: str = "Immediately"
|
|
51
|
+
german_level: str = "B1"
|
|
52
|
+
english_level: str = "C2"
|
|
53
|
+
hybrid_onsite_ok: bool = True
|
|
54
|
+
residing_in_germany: bool = True
|
|
55
|
+
willing_to_relocate: bool = True
|
|
56
|
+
referral_source: str = "Company Website"
|
|
57
|
+
auto_consent: bool = True
|
|
58
|
+
gender: str = "Decline to self-identify"
|
|
59
|
+
pronouns: str = "Prefer not to say"
|
|
60
|
+
highest_degree: str = "Master's"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def full_name(self) -> str:
|
|
64
|
+
return f"{self.first_name} {self.last_name}".strip()
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def contact_line(self) -> str:
|
|
68
|
+
parts = [self.email, self.phone, self.city]
|
|
69
|
+
return " · ".join(p for p in parts if p)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_config_dir() -> Path:
|
|
73
|
+
env_dir = os.environ.get("JOBKATZE_CONFIG_DIR")
|
|
74
|
+
if env_dir:
|
|
75
|
+
path = Path(env_dir)
|
|
76
|
+
elif xdg := os.environ.get("XDG_CONFIG_HOME"):
|
|
77
|
+
path = Path(xdg) / "jobkatze"
|
|
78
|
+
else:
|
|
79
|
+
path = Path.home() / ".config" / "jobkatze"
|
|
80
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
return path
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_config() -> AppConfig:
|
|
85
|
+
path = get_config_dir() / CONFIG_FILE
|
|
86
|
+
if not path.exists():
|
|
87
|
+
return AppConfig()
|
|
88
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
89
|
+
return AppConfig(
|
|
90
|
+
api_url=data.get("api_url", PROD_API_URL).rstrip("/"),
|
|
91
|
+
token=data.get("token", ""),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def save_config(config: AppConfig) -> None:
|
|
96
|
+
path = get_config_dir() / CONFIG_FILE
|
|
97
|
+
content = f'api_url = "{config.api_url}"\ntoken = "{config.token}"\n'
|
|
98
|
+
path.write_text(content, encoding="utf-8")
|
|
99
|
+
path.chmod(0o600)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_profile() -> CandidateProfile:
|
|
103
|
+
path = get_config_dir() / PROFILE_FILE
|
|
104
|
+
if not path.exists():
|
|
105
|
+
return CandidateProfile()
|
|
106
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
107
|
+
eu_permit = bool(data.get("eu_work_permit", True))
|
|
108
|
+
return CandidateProfile(
|
|
109
|
+
first_name=str(data.get("first_name", "")),
|
|
110
|
+
last_name=str(data.get("last_name", "")),
|
|
111
|
+
email=str(data.get("email", "")),
|
|
112
|
+
phone=str(data.get("phone", "")),
|
|
113
|
+
city=str(data.get("city", "")),
|
|
114
|
+
country=str(data.get("country", "Germany")),
|
|
115
|
+
linkedin=str(data.get("linkedin", "")),
|
|
116
|
+
github=str(data.get("github", "")),
|
|
117
|
+
website=str(data.get("website", "")),
|
|
118
|
+
nationality=str(data.get("nationality", "")),
|
|
119
|
+
work_permit_type=str(data.get("work_permit_type", "")),
|
|
120
|
+
eu_work_permit=eu_permit,
|
|
121
|
+
requires_sponsorship=bool(data.get("requires_sponsorship", not eu_permit)),
|
|
122
|
+
salary_expectation_eur=int(data.get("salary_expectation_eur", 0)),
|
|
123
|
+
earliest_start=str(data.get("earliest_start", "")),
|
|
124
|
+
notice_period=str(data.get("notice_period", "Immediately")),
|
|
125
|
+
german_level=str(data.get("german_level", "B1")),
|
|
126
|
+
english_level=str(data.get("english_level", "C2")),
|
|
127
|
+
hybrid_onsite_ok=bool(data.get("hybrid_onsite_ok", True)),
|
|
128
|
+
residing_in_germany=bool(data.get("residing_in_germany", True)),
|
|
129
|
+
willing_to_relocate=bool(data.get("willing_to_relocate", True)),
|
|
130
|
+
referral_source=str(data.get("referral_source", "Company Website")),
|
|
131
|
+
auto_consent=bool(data.get("auto_consent", True)),
|
|
132
|
+
gender=str(data.get("gender", "Decline to self-identify")),
|
|
133
|
+
pronouns=str(data.get("pronouns", "Prefer not to say")),
|
|
134
|
+
highest_degree=str(data.get("highest_degree", "Master's")),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def profile_field_names() -> list[str]:
|
|
139
|
+
return [f.name for f in fields(CandidateProfile)]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def coerce_profile_value(field_name: str, raw: object) -> object:
|
|
143
|
+
by_name = {f.name: f for f in fields(CandidateProfile)}
|
|
144
|
+
if field_name not in by_name:
|
|
145
|
+
raise KeyError(f"Unknown profile field: {field_name}")
|
|
146
|
+
ftype = by_name[field_name].type
|
|
147
|
+
if ftype in (bool, "bool"):
|
|
148
|
+
if isinstance(raw, bool):
|
|
149
|
+
return raw
|
|
150
|
+
val = str(raw).strip().lower()
|
|
151
|
+
if val in ("1", "true", "yes", "y", "on"):
|
|
152
|
+
return True
|
|
153
|
+
if val in ("0", "false", "no", "n", "off"):
|
|
154
|
+
return False
|
|
155
|
+
raise ValueError(f"Expected boolean (true/false) for {field_name}, got {raw!r}")
|
|
156
|
+
if ftype in (int, "int"):
|
|
157
|
+
return int(str(raw).strip().replace(",", "").replace("_", ""))
|
|
158
|
+
return str(raw).strip()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def save_profile(profile: CandidateProfile, path: Path | None = None) -> Path:
|
|
162
|
+
target = path or (get_config_dir() / PROFILE_FILE)
|
|
163
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
target.write_text(
|
|
165
|
+
yaml.safe_dump(asdict(profile), sort_keys=False, allow_unicode=True),
|
|
166
|
+
encoding="utf-8",
|
|
167
|
+
)
|
|
168
|
+
return target
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve_resume_path() -> Path:
|
|
172
|
+
return get_config_dir() / RESUME_FILE
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def ensure_initialized(resume: Path | None = None) -> Path:
|
|
176
|
+
import shutil
|
|
177
|
+
|
|
178
|
+
base_dir = get_config_dir()
|
|
179
|
+
profile_path = base_dir / PROFILE_FILE
|
|
180
|
+
typst_target = base_dir / TYPST_FILE
|
|
181
|
+
if resume is not None:
|
|
182
|
+
shutil.copyfile(resume, base_dir / RESUME_FILE)
|
|
183
|
+
save_profile_template(profile_path)
|
|
184
|
+
source_typst = Path(__file__).resolve().parent.parent.parent / ".agents" / "skills" / "cover-letter-generator" / "letter.typ"
|
|
185
|
+
if source_typst.exists() and not typst_target.exists():
|
|
186
|
+
shutil.copyfile(source_typst, typst_target)
|
|
187
|
+
elif not typst_target.exists():
|
|
188
|
+
typst_target.write_text(
|
|
189
|
+
'#set page(paper: "a4", margin: 2.5cm)\n#let body = read(sys.inputs.at("body"))\n#par(body)\n',
|
|
190
|
+
encoding="utf-8",
|
|
191
|
+
)
|
|
192
|
+
return base_dir
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def save_profile_template(path: Path) -> None:
|
|
196
|
+
template = """first_name: Alex
|
|
197
|
+
last_name: Müller
|
|
198
|
+
email: alex@example.com
|
|
199
|
+
phone: "+49 170 1234567"
|
|
200
|
+
city: Berlin
|
|
201
|
+
country: Germany
|
|
202
|
+
linkedin: https://linkedin.com/in/alexmueller
|
|
203
|
+
github: https://github.com/alexmueller
|
|
204
|
+
website: https://alexmueller.dev
|
|
205
|
+
nationality: German
|
|
206
|
+
work_permit_type: "EU Citizen / Full Work Authorization"
|
|
207
|
+
eu_work_permit: true
|
|
208
|
+
requires_sponsorship: false
|
|
209
|
+
salary_expectation_eur: 85000
|
|
210
|
+
earliest_start: "2026-12-01"
|
|
211
|
+
notice_period: "Immediately"
|
|
212
|
+
german_level: "B1"
|
|
213
|
+
english_level: "C2"
|
|
214
|
+
hybrid_onsite_ok: true
|
|
215
|
+
residing_in_germany: true
|
|
216
|
+
willing_to_relocate: true
|
|
217
|
+
referral_source: "Company Website"
|
|
218
|
+
auto_consent: true
|
|
219
|
+
gender: "Decline to self-identify"
|
|
220
|
+
pronouns: "Prefer not to say"
|
|
221
|
+
highest_degree: "Master's"
|
|
222
|
+
"""
|
|
223
|
+
if not path.exists():
|
|
224
|
+
path.write_text(template, encoding="utf-8")
|