divapply 0.4.2__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.
@@ -0,0 +1,113 @@
1
+ # DivApply search configuration
2
+ # Copy to ~/.divapply/searches.yaml and customize for your job search.
3
+ # The wizard (divapply init) generates a basic version automatically.
4
+
5
+ # Search queries -- each has a query string and priority tier.
6
+ # Tier 1: High-priority exact matches (searched first, highest precision)
7
+ # Tier 2: Strong matches, broader scope
8
+ # Tier 3: Wide net, high volume, lower precision
9
+ queries:
10
+ - query: "software engineer"
11
+ tier: 1
12
+ - query: "backend engineer"
13
+ tier: 1
14
+ - query: "backend developer"
15
+ tier: 1
16
+ - query: "full stack developer"
17
+ tier: 2
18
+ - query: "full stack engineer"
19
+ tier: 2
20
+ - query: "python developer"
21
+ tier: 2
22
+ - query: "python engineer"
23
+ tier: 2
24
+ - query: "DevOps engineer"
25
+ tier: 2
26
+ - query: "site reliability engineer"
27
+ tier: 2
28
+ - query: "platform engineer"
29
+ tier: 2
30
+ - query: "cloud engineer"
31
+ tier: 2
32
+ - query: "data engineer"
33
+ tier: 2
34
+ - query: "infrastructure engineer"
35
+ tier: 2
36
+ - query: "software developer"
37
+ tier: 3
38
+ - query: "developer"
39
+ tier: 3
40
+ - query: "systems engineer"
41
+ tier: 3
42
+ - query: "automation engineer"
43
+ tier: 3
44
+ - query: "solutions engineer"
45
+ tier: 3
46
+ - query: "integration engineer"
47
+ tier: 3
48
+ - query: "ML engineer"
49
+ tier: 3
50
+ - query: "machine learning engineer"
51
+ tier: 3
52
+ - query: "AI engineer"
53
+ tier: 3
54
+ - query: "security engineer"
55
+ tier: 3
56
+
57
+ # Locations to search
58
+ locations:
59
+ - location: "San Francisco, CA"
60
+ remote: false
61
+ - location: "Remote"
62
+ remote: true
63
+
64
+ # Location filtering -- accept/reject patterns for job location text
65
+ location:
66
+ accept_patterns:
67
+ - "San Francisco"
68
+ - "Bay Area"
69
+ - "SF"
70
+ - "California"
71
+ - "CA"
72
+ - "Remote"
73
+ - "Anywhere"
74
+ - "United States"
75
+ - "US"
76
+ - "USA"
77
+ reject_patterns:
78
+ - "New York only"
79
+ - "Seattle only"
80
+ - "onsite only"
81
+ - "London, UK"
82
+ - "India"
83
+ - "Philippines"
84
+
85
+ # Country for Indeed/LinkedIn/Google Jobs searches
86
+ country: "USA"
87
+
88
+ # JobSpy source boards (all available options shown)
89
+ boards:
90
+ - indeed
91
+ - linkedin
92
+ - glassdoor
93
+ - zip_recruiter
94
+ - google
95
+
96
+ # Search defaults
97
+ defaults:
98
+ results_per_site: 100 # Max results per board per query
99
+ hours_old: 72 # Only jobs posted within this many hours
100
+
101
+ # Negative keywords -- skip jobs with these in the title (case-insensitive)
102
+ exclude_titles:
103
+ - "senior director"
104
+ - "VP "
105
+ - "vice president"
106
+ - "chief"
107
+ - "intern"
108
+ - "internship"
109
+ - "co-op"
110
+ - "clearance required"
111
+ - "TS/SCI"
112
+ - "principal scientist"
113
+
@@ -0,0 +1,107 @@
1
+ # Direct career site registry for DivApply — Sacramento government focus
2
+ # Dalton De Arrieta: IT support, admin, procurement, fiscal roles
3
+ # All remote developer boards removed — not relevant to this candidate's profile.
4
+
5
+ # ── Manual-Only ATS Domains ──────────────────────────────────────────────
6
+ # These ATS platforms have unsolvable CAPTCHAs. Jobs routing through them
7
+ # are flagged as "manual" during apply — bot skips, user applies by hand.
8
+ manual_ats:
9
+ - "ibegin.tcsapps.com" # TCS iBegin — unsolvable CAPTCHA every time
10
+
11
+ # ── Blocked sites — too problematic for automation ───────────────────────
12
+ blocked:
13
+ sites:
14
+ - "glassdoor"
15
+ - "google"
16
+ url_patterns:
17
+ - "%glassdoor%"
18
+ - "%google.com/about/careers%"
19
+ - "%google.jobs%"
20
+
21
+ # ── Blocked SSO domains — cannot authenticate through these ──────────────
22
+ blocked_sso:
23
+ - "login.microsoftonline.com"
24
+ - "okta.com"
25
+ - "auth0.com"
26
+
27
+ # ── Base URLs for URL resolution ─────────────────────────────────────────
28
+ base_urls:
29
+ "GovernmentJobs.com": "https://www.governmentjobs.com"
30
+ # "City of Roseville": "https://www.governmentjobs.com"
31
+ # "Placer County": "https://www.jobapscloud.com"
32
+ # "Sacramento County": "https://www.governmentjobs.com"
33
+ # "Nevada County": "https://www.governmentjobs.com"
34
+ # "City of Sacramento": "https://www.governmentjobs.com"
35
+ # "City of Folsom": "https://www.governmentjobs.com"
36
+ # "City of Elk Grove": "https://www.governmentjobs.com"
37
+ # "City of Rocklin": "https://www.governmentjobs.com"
38
+ # "City of Citrus Heights": "https://www.governmentjobs.com"
39
+ # "City of Rancho Cordova": "https://www.governmentjobs.com"
40
+ # "City of Auburn": "https://www.governmentjobs.com"
41
+ # "CalCareers": "https://calcareers.ca.gov"
42
+ "SimplyHired": "https://www.simplyhired.com"
43
+
44
+ sites:
45
+ # ── GovernmentJobs.com (NEOGOV) — used by nearly all CA government agencies ──
46
+ # Search type: expands per query × location combo from searches.yaml
47
+ # - name: "GovernmentJobs.com"
48
+ # url: "https://www.governmentjobs.com/jobs?keyword={query_encoded}&location={location_encoded}&radius=30"
49
+ # type: search
50
+
51
+ # ── General job search with location ─────────────────────────────────────
52
+ - name: "SimplyHired"
53
+ url: "https://www.simplyhired.com/search?q={query_encoded}&l={location_encoded}"
54
+ type: search
55
+
56
+ # ── Static agency pages — Sacramento region government portals ────────────
57
+ # These are scraped once (no query/location params) and filtered by keyword.
58
+
59
+ # - name: "City of Roseville"
60
+ # url: "https://www.governmentjobs.com/careers/rosevilleca"
61
+ # type: static
62
+ #
63
+ # - name: "Placer County"
64
+ # url: "https://www.jobapscloud.com/Placer/JobBoard.asp"
65
+ # type: static
66
+ #
67
+ # - name: "Sacramento County"
68
+ # url: "https://www.governmentjobs.com/careers/sacramento"
69
+ # type: static
70
+ #
71
+ # - name: "City of Sacramento"
72
+ # url: "https://www.governmentjobs.com/careers/saccity"
73
+ # type: static
74
+ #
75
+ # - name: "City of Folsom"
76
+ # url: "https://www.governmentjobs.com/careers/folsom"
77
+ # type: static
78
+ #
79
+ # - name: "City of Elk Grove"
80
+ # url: "https://www.governmentjobs.com/careers/elkgrove"
81
+ # type: static
82
+ #
83
+ # - name: "City of Rocklin"
84
+ # url: "https://www.governmentjobs.com/careers/rocklinca"
85
+ # type: static
86
+ #
87
+ # - name: "City of Citrus Heights"
88
+ # url: "https://www.governmentjobs.com/careers/citrusheights"
89
+ # type: static
90
+ #
91
+ # - name: "City of Rancho Cordova"
92
+ # url: "https://www.governmentjobs.com/careers/ranchocordova"
93
+ # type: static
94
+ #
95
+ # - name: "Nevada County"
96
+ # url: "https://www.governmentjobs.com/careers/mynevadacounty"
97
+ # type: static
98
+ #
99
+ # - name: "City of Auburn"
100
+ # url: "https://www.governmentjobs.com/careers/auburn"
101
+ # type: static
102
+ #
103
+ # # City of Lincoln uses CalOpps (not NEOGOV) — not compatible with this scraper
104
+ #
105
+ # - name: "CalCareers"
106
+ # url: "https://calcareers.ca.gov/CalHRPublic/Search/JobSearchResults.aspx"
107
+ # type: static
divapply/config.py ADDED
@@ -0,0 +1,445 @@
1
+ """DivApply configuration: paths, platform detection, user data."""
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ # User data directory - all user-specific files live here.
9
+ #
10
+ # New installs default to ~/.divapply, but we still read legacy ~/.applypilot
11
+ # files so existing users can migrate without losing data.
12
+ APP_DIR = Path(os.environ.get("DIVAPPLY_DIR", os.environ.get("APPLYPILOT_DIR", Path.home() / ".divapply")))
13
+ LEGACY_APP_DIR = Path(os.environ.get("APPLYPILOT_DIR", Path.home() / ".applypilot"))
14
+
15
+ # Core paths
16
+ DB_PATH = APP_DIR / "divapply.db"
17
+ PROFILE_PATH = APP_DIR / "profile.json"
18
+ RESUME_PATH = APP_DIR / "resume.txt"
19
+ RESUME_PDF_PATH = APP_DIR / "resume.pdf"
20
+ SEARCH_CONFIG_PATH = APP_DIR / "searches.yaml"
21
+ ENV_PATH = APP_DIR / ".env"
22
+ LEGACY_DB_PATH = LEGACY_APP_DIR / "applypilot.db"
23
+ LEGACY_PROFILE_PATH = LEGACY_APP_DIR / "profile.json"
24
+ LEGACY_SEARCH_CONFIG_PATH = LEGACY_APP_DIR / "searches.yaml"
25
+ LEGACY_ENV_PATH = LEGACY_APP_DIR / ".env"
26
+
27
+ # Generated output
28
+ TAILORED_DIR = APP_DIR / "tailored_resumes"
29
+ COVER_LETTER_DIR = APP_DIR / "cover_letters"
30
+ LOG_DIR = APP_DIR / "logs"
31
+
32
+ # Chrome worker isolation
33
+ CHROME_WORKER_DIR = APP_DIR / "chrome-workers"
34
+ APPLY_WORKER_DIR = APP_DIR / "apply-workers"
35
+
36
+ # Package-shipped config (YAML registries)
37
+ PACKAGE_DIR = Path(__file__).parent
38
+ CONFIG_DIR = PACKAGE_DIR / "config"
39
+
40
+
41
+ def get_chrome_path() -> str:
42
+ """Auto-detect Chrome/Chromium executable path, cross-platform.
43
+
44
+ Override with CHROME_PATH environment variable.
45
+ """
46
+ env_path = os.environ.get("CHROME_PATH")
47
+ if env_path and Path(env_path).exists():
48
+ return env_path
49
+
50
+ system = platform.system()
51
+
52
+ if system == "Windows":
53
+ candidates = [
54
+ Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "Google/Chrome/Application/chrome.exe",
55
+ Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) / "Google/Chrome/Application/chrome.exe",
56
+ Path(os.environ.get("LOCALAPPDATA", "")) / "Google/Chrome/Application/chrome.exe",
57
+ ]
58
+ elif system == "Darwin":
59
+ candidates = [
60
+ Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
61
+ Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
62
+ ]
63
+ else:
64
+ candidates = []
65
+ for name in ("google-chrome", "google-chrome-stable", "chromium-browser", "chromium"):
66
+ found = shutil.which(name)
67
+ if found:
68
+ candidates.append(Path(found))
69
+
70
+ for candidate in candidates:
71
+ if candidate and candidate.exists():
72
+ return str(candidate)
73
+
74
+ for name in ("google-chrome", "google-chrome-stable", "chromium-browser", "chromium", "chrome"):
75
+ found = shutil.which(name)
76
+ if found:
77
+ return found
78
+
79
+ raise FileNotFoundError(
80
+ "Chrome/Chromium not found. Install Chrome or set CHROME_PATH environment variable."
81
+ )
82
+
83
+
84
+ def get_chrome_user_data() -> Path:
85
+ """Default Chrome user data directory, cross-platform."""
86
+ system = platform.system()
87
+ if system == "Windows":
88
+ return Path(os.environ.get("LOCALAPPDATA", "")) / "Google" / "Chrome" / "User Data"
89
+ if system == "Darwin":
90
+ return Path.home() / "Library" / "Application Support" / "Google" / "Chrome"
91
+ return Path.home() / ".config" / "google-chrome"
92
+
93
+
94
+ def ensure_dirs() -> None:
95
+ """Create all required directories."""
96
+ for directory in [APP_DIR, TAILORED_DIR, COVER_LETTER_DIR, LOG_DIR, CHROME_WORKER_DIR, APPLY_WORKER_DIR]:
97
+ directory.mkdir(parents=True, exist_ok=True)
98
+
99
+
100
+ def _read_text_with_legacy(current: Path, legacy: Path | None = None, *, encoding: str = "utf-8") -> str | None:
101
+ """Read current user data, falling back to a legacy file."""
102
+ if current.exists():
103
+ return current.read_text(encoding=encoding)
104
+ if legacy is not None and legacy.exists():
105
+ return legacy.read_text(encoding=encoding)
106
+ return None
107
+
108
+
109
+ def _copy_if_present(source: Path, target: Path, *, overwrite: bool = False) -> str:
110
+ """Copy a file when available and report the action taken."""
111
+ if not source.exists():
112
+ return "missing"
113
+ try:
114
+ if source.resolve() == target.resolve():
115
+ return "skipped"
116
+ except Exception:
117
+ pass
118
+ if target.exists() and not overwrite:
119
+ return "skipped"
120
+ target.parent.mkdir(parents=True, exist_ok=True)
121
+ shutil.copy2(source, target)
122
+ return "copied"
123
+
124
+
125
+ def migrate_legacy_user_data(
126
+ *,
127
+ source_dir: Path | None = None,
128
+ target_dir: Path | None = None,
129
+ overwrite: bool = False,
130
+ ) -> dict[str, str]:
131
+ """Copy legacy files into the current DivApply layout.
132
+
133
+ This intentionally preserves the current files unless overwrite=True.
134
+ The helper is used by the CLI migrate command and is safe to call on a
135
+ fresh install or repeatedly during upgrade troubleshooting.
136
+ """
137
+ source_root = Path(source_dir or LEGACY_APP_DIR)
138
+ target_root = Path(target_dir or APP_DIR)
139
+ results = {
140
+ "profile": _copy_if_present(source_root / "profile.json", target_root / "profile.json", overwrite=overwrite),
141
+ "searches": _copy_if_present(source_root / "searches.yaml", target_root / "searches.yaml", overwrite=overwrite),
142
+ "env": _copy_if_present(source_root / ".env", target_root / ".env", overwrite=overwrite),
143
+ "resume_txt": _copy_if_present(source_root / "resume.txt", target_root / "resume.txt", overwrite=overwrite),
144
+ "resume_pdf": _copy_if_present(source_root / "resume.pdf", target_root / "resume.pdf", overwrite=overwrite),
145
+ "database": _copy_if_present(source_root / "applypilot.db", target_root / "divapply.db", overwrite=overwrite),
146
+ }
147
+ return results
148
+
149
+
150
+ def load_profile() -> dict:
151
+ """Load user profile from ~/.divapply/profile.json."""
152
+ import json
153
+
154
+ raw = _read_text_with_legacy(PROFILE_PATH, LEGACY_PROFILE_PATH)
155
+ if raw is None:
156
+ raise FileNotFoundError(
157
+ f"Profile not found at {PROFILE_PATH}. Run `divapply init` first."
158
+ )
159
+ profile = json.loads(raw)
160
+
161
+ # Hidden coursework knowledge is stored in SQLite so it can inform
162
+ # scoring/tailoring without being exposed in the generated resume text.
163
+ try:
164
+ from divapply.database import get_coursework, load_coursework_seed
165
+
166
+ coursework = get_coursework()
167
+ if not coursework:
168
+ coursework = load_coursework_seed()
169
+ except Exception:
170
+ coursework = []
171
+
172
+ profile["coursework"] = coursework
173
+ profile["coursework_summary"] = _summarize_coursework(coursework)
174
+ profile["coursework_skills"] = _summarize_coursework_skills(coursework)
175
+ return profile
176
+
177
+
178
+ def _summarize_coursework(coursework: list[dict]) -> list[str]:
179
+ """Condense coursework rows into a compact internal-only summary."""
180
+ if not coursework:
181
+ return []
182
+
183
+ grouped: dict[str, list[str]] = {}
184
+ for row in coursework:
185
+ school = (row.get("school") or "Unknown school").strip()
186
+ title = (row.get("course_title") or row.get("course_code") or "").strip()
187
+ subject = (row.get("subject_area") or "").strip()
188
+ if subject:
189
+ item = f"{title} [{subject}]" if title else subject
190
+ else:
191
+ raw = (row.get("raw_text") or "").strip()
192
+ if raw:
193
+ first_line = raw.splitlines()[0].strip()
194
+ item = title or (first_line[:120] + ("..." if len(first_line) > 120 else ""))
195
+ else:
196
+ item = title
197
+ if not item:
198
+ continue
199
+ grouped.setdefault(school, [])
200
+ if item not in grouped[school]:
201
+ grouped[school].append(item)
202
+
203
+ summary: list[str] = []
204
+ for school, items in grouped.items():
205
+ summary.append(f"{school}: {', '.join(items[:12])}")
206
+ return summary
207
+
208
+
209
+ def _summarize_coursework_skills(coursework: list[dict]) -> list[str]:
210
+ """Aggregate coursework skill tags into a compact internal-only summary."""
211
+ if not coursework:
212
+ return []
213
+
214
+ grouped: dict[str, set[str]] = {}
215
+ for row in coursework:
216
+ school = (row.get("school") or "Unknown school").strip()
217
+ raw_skills = row.get("skills") or []
218
+ if isinstance(raw_skills, str):
219
+ try:
220
+ import json
221
+ raw_skills = json.loads(raw_skills)
222
+ except Exception:
223
+ raw_skills = [s.strip() for s in raw_skills.split(",") if s.strip()]
224
+ if not isinstance(raw_skills, list):
225
+ continue
226
+ grouped.setdefault(school, set()).update(str(skill).strip() for skill in raw_skills if str(skill).strip())
227
+
228
+ summary: list[str] = []
229
+ for school, skills in grouped.items():
230
+ summary.append(f"{school}: {', '.join(sorted(skills))}")
231
+ return summary
232
+
233
+
234
+ def load_search_config() -> dict:
235
+ """Load search configuration from ~/.divapply/searches.yaml."""
236
+ import yaml
237
+
238
+ raw = _read_text_with_legacy(SEARCH_CONFIG_PATH, LEGACY_SEARCH_CONFIG_PATH)
239
+ if raw is None:
240
+ example = CONFIG_DIR / "searches.example.yaml"
241
+ if example.exists():
242
+ return yaml.safe_load(example.read_text(encoding="utf-8"))
243
+ return {}
244
+ return yaml.safe_load(raw)
245
+
246
+
247
+ def load_sites_config() -> dict:
248
+ """Load sites.yaml configuration (sites list, manual_ats, blocked, etc.)."""
249
+ import yaml
250
+
251
+ path = CONFIG_DIR / "sites.yaml"
252
+ if not path.exists():
253
+ return {}
254
+ return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
255
+
256
+
257
+ def is_manual_ats(url: str | None) -> bool:
258
+ """Check if a URL routes through an ATS that requires manual application."""
259
+ if not url:
260
+ return False
261
+ sites_cfg = load_sites_config()
262
+ domains = sites_cfg.get("manual_ats", [])
263
+ url_lower = url.lower()
264
+ return any(domain in url_lower for domain in domains)
265
+
266
+
267
+ def load_blocked_sites() -> tuple[set[str], list[str]]:
268
+ """Load blocked sites and URL patterns from sites.yaml."""
269
+ cfg = load_sites_config()
270
+ blocked = cfg.get("blocked", {})
271
+ sites = set(blocked.get("sites", []))
272
+ patterns = blocked.get("url_patterns", [])
273
+ return sites, patterns
274
+
275
+
276
+ def load_blocked_sso() -> list[str]:
277
+ """Load blocked SSO domains from sites.yaml."""
278
+ cfg = load_sites_config()
279
+ return cfg.get("blocked_sso", [])
280
+
281
+
282
+ def load_base_urls() -> dict[str, str | None]:
283
+ """Load site base URLs for URL resolution from sites.yaml."""
284
+ cfg = load_sites_config()
285
+ return cfg.get("base_urls", {})
286
+
287
+
288
+ DEFAULTS = {
289
+ "min_score": 7,
290
+ "max_apply_attempts": 3,
291
+ "max_tailor_attempts": 5,
292
+ "poll_interval": 60,
293
+ "apply_timeout": 300,
294
+ "viewport": "1280x900",
295
+ }
296
+
297
+
298
+ def load_env() -> None:
299
+ """Load environment variables from ~/.divapply/.env if it exists."""
300
+ from dotenv import load_dotenv
301
+
302
+ if ENV_PATH.exists():
303
+ load_dotenv(ENV_PATH, override=True)
304
+ elif LEGACY_ENV_PATH.exists():
305
+ load_dotenv(LEGACY_ENV_PATH, override=True)
306
+ load_dotenv()
307
+
308
+
309
+ TIER_LABELS = {
310
+ 1: "Discovery",
311
+ 2: "AI Scoring & Tailoring",
312
+ 3: "Full Auto-Apply",
313
+ }
314
+
315
+ TIER_COMMANDS: dict[int, list[str]] = {
316
+ 1: ["init", "run discover", "run enrich", "status", "dashboard"],
317
+ 2: ["run score", "run tailor", "run cover", "run pdf", "run"],
318
+ 3: ["apply"],
319
+ }
320
+
321
+ APPLY_AGENT_LABELS = {
322
+ "codex": "Codex CLI",
323
+ "claude": "Claude Code CLI",
324
+ }
325
+
326
+ APPLY_BROWSER_LABELS = {
327
+ "firefox": "Firefox",
328
+ "chrome": "Google Chrome",
329
+ "msedge": "Microsoft Edge",
330
+ "webkit": "WebKit",
331
+ }
332
+
333
+
334
+ def get_available_apply_backends() -> dict[str, str]:
335
+ """Return detected auto-apply agent CLIs keyed by backend name."""
336
+ backends: dict[str, str] = {}
337
+ for name, binary in (("codex", "codex"), ("claude", "claude")):
338
+ resolved = shutil.which(binary)
339
+ if resolved:
340
+ backends[name] = resolved
341
+ return backends
342
+
343
+
344
+ def get_apply_backend(preferred: str | None = None) -> str | None:
345
+ """Resolve which apply backend to use."""
346
+ requested = preferred or os.environ.get("DIVAPPLY_APPLY_BACKEND") or os.environ.get("APPLYPILOT_APPLY_BACKEND")
347
+ available = get_available_apply_backends()
348
+ if requested:
349
+ requested = requested.strip().lower()
350
+ return requested if requested in available else None
351
+ for name in ("codex", "claude"):
352
+ if name in available:
353
+ return name
354
+ return None
355
+
356
+
357
+ def get_apply_backend_label(backend: str | None) -> str:
358
+ """Return a human-readable label for the apply backend."""
359
+ if not backend:
360
+ return "Apply agent CLI"
361
+ return APPLY_AGENT_LABELS.get(backend, backend)
362
+
363
+
364
+ def get_apply_browser(preferred: str | None = None) -> str:
365
+ """Resolve which Playwright MCP browser channel to use."""
366
+ requested = preferred or os.environ.get("DIVAPPLY_BROWSER") or os.environ.get("APPLYPILOT_BROWSER") or "firefox"
367
+ browser = requested.strip().lower()
368
+ return browser if browser in APPLY_BROWSER_LABELS else "firefox"
369
+
370
+
371
+ def get_apply_browser_label(browser: str | None) -> str:
372
+ """Return a human-readable label for the apply browser."""
373
+ if not browser:
374
+ return "Browser"
375
+ return APPLY_BROWSER_LABELS.get(browser, browser)
376
+
377
+
378
+ def get_tier(preferred_backend: str | None = None, preferred_browser: str | None = None) -> int:
379
+ """Detect the current tier based on available dependencies.
380
+
381
+ Args:
382
+ preferred_backend: Optional backend to evaluate instead of the env/default backend.
383
+ preferred_browser: Optional browser to evaluate instead of the env/default browser.
384
+ """
385
+ load_env()
386
+
387
+ has_llm = any(os.environ.get(key) for key in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL"))
388
+ if not has_llm:
389
+ return 1
390
+
391
+ has_apply_agent = get_apply_backend(preferred_backend) is not None
392
+ browser = get_apply_browser(preferred_browser)
393
+ has_browser = True
394
+ if browser == "chrome":
395
+ try:
396
+ get_chrome_path()
397
+ except FileNotFoundError:
398
+ has_browser = False
399
+
400
+ if has_apply_agent and has_browser and shutil.which("npx") is not None:
401
+ return 3
402
+
403
+ return 2
404
+
405
+
406
+ def check_tier(
407
+ required: int,
408
+ feature: str,
409
+ preferred_backend: str | None = None,
410
+ preferred_browser: str | None = None,
411
+ ) -> None:
412
+ """Raise SystemExit with a clear message if the current tier is too low."""
413
+ current = get_tier(preferred_backend=preferred_backend, preferred_browser=preferred_browser)
414
+ if current >= required:
415
+ return
416
+
417
+ from rich.console import Console
418
+
419
+ console = Console(stderr=True)
420
+ missing: list[str] = []
421
+
422
+ if required >= 2 and not any(os.environ.get(key) for key in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL")):
423
+ missing.append("LLM API key — run [bold]divapply init[/bold] or set GEMINI_API_KEY")
424
+ if required >= 3:
425
+ if get_apply_backend(preferred_backend) is None:
426
+ missing.append("Apply agent CLI — install Codex or Claude Code for auto-apply")
427
+ if shutil.which("npx") is None:
428
+ missing.append("Node.js / npx — install Node.js 18+ for Playwright MCP")
429
+ if get_apply_browser(preferred_browser) == "chrome":
430
+ try:
431
+ get_chrome_path()
432
+ except FileNotFoundError:
433
+ missing.append("Chrome/Chromium — install or set CHROME_PATH")
434
+
435
+ console.print(
436
+ f"\n[red]'{feature}' requires {TIER_LABELS.get(required, f'Tier {required}')} (Tier {required}).[/red]\n"
437
+ f"Current tier: {TIER_LABELS.get(current, f'Tier {current}')} (Tier {current})."
438
+ )
439
+ if missing:
440
+ console.print("\n[yellow]Missing:[/yellow]")
441
+ for item in missing:
442
+ console.print(f" - {item}")
443
+ console.print()
444
+ raise SystemExit(1)
445
+