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 ADDED
@@ -0,0 +1,4 @@
1
+ """JobKatze CLI package."""
2
+
3
+ __version__ = "0.1.0"
4
+
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")