chameleon-cv 0.1.0
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.
- package/AGENTS.md +186 -0
- package/LICENSE +21 -0
- package/Makefile +136 -0
- package/README.md +356 -0
- package/bin/chameleon.js +136 -0
- package/chameleon.bat +286 -0
- package/chameleon.sh +353 -0
- package/package.json +39 -0
- package/profiles/github_repos.json +1266 -0
- package/requirements.txt +5 -0
- package/scan.sh +12 -0
- package/score.sh +12 -0
- package/scripts/__init__.py +1 -0
- package/scripts/__main_tui__.py +11 -0
- package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/__pycache__/__main_tui__.cpython-313.pyc +0 -0
- package/scripts/__pycache__/ats_ghost.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cache.cpython-313.pyc +0 -0
- package/scripts/__pycache__/config.cpython-312.pyc +0 -0
- package/scripts/__pycache__/config.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cover_letter.cpython-313.pyc +0 -0
- package/scripts/__pycache__/db.cpython-312.pyc +0 -0
- package/scripts/__pycache__/db.cpython-313.pyc +0 -0
- package/scripts/__pycache__/fonts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fonts.cpython-313.pyc +0 -0
- package/scripts/__pycache__/ghost.cpython-313.pyc +0 -0
- package/scripts/__pycache__/job_matcher.cpython-312.pyc +0 -0
- package/scripts/__pycache__/job_matcher.cpython-313.pyc +0 -0
- package/scripts/__pycache__/question.cpython-313.pyc +0 -0
- package/scripts/__pycache__/render.cpython-313.pyc +0 -0
- package/scripts/__pycache__/review.cpython-313.pyc +0 -0
- package/scripts/__pycache__/setup.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_brief.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_brief.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tailor_cv.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_cv.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tailor_prompts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_prompts.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tui_app.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tui_app.cpython-313.pyc +0 -0
- package/scripts/ats_ghost.py +170 -0
- package/scripts/cache.py +63 -0
- package/scripts/cli.py +109 -0
- package/scripts/config.py +376 -0
- package/scripts/cover_letter.py +105 -0
- package/scripts/db.py +331 -0
- package/scripts/fonts.py +186 -0
- package/scripts/ghost.py +88 -0
- package/scripts/job_matcher.py +636 -0
- package/scripts/job_scanner/__init__.py +8 -0
- package/scripts/job_scanner/__main__.py +6 -0
- package/scripts/job_scanner/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__main__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__main__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/base.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/base.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/browser.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/browser.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/cli.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/registry.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/registry.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/scanner.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/scanner.cpython-313.pyc +0 -0
- package/scripts/job_scanner/base.py +197 -0
- package/scripts/job_scanner/browser.py +244 -0
- package/scripts/job_scanner/cli.py +6 -0
- package/scripts/job_scanner/parsers/__init__.py +24 -0
- package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/arc.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/arc.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/flexjobs.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/indeed.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/linkedin.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/opentoworkremote.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remote_rocketship.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/web3_career.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/wellfound.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/ycomb.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/adzuna.py +81 -0
- package/scripts/job_scanner/parsers/arc.py +65 -0
- package/scripts/job_scanner/parsers/crypto_careers.py +88 -0
- package/scripts/job_scanner/parsers/dailyremote.py +91 -0
- package/scripts/job_scanner/parsers/flexjobs.py +91 -0
- package/scripts/job_scanner/parsers/himalayas.py +47 -0
- package/scripts/job_scanner/parsers/hn_hiring.py +94 -0
- package/scripts/job_scanner/parsers/indeed.py +98 -0
- package/scripts/job_scanner/parsers/jobicy.py +60 -0
- package/scripts/job_scanner/parsers/jooble.py +68 -0
- package/scripts/job_scanner/parsers/linkedin.py +140 -0
- package/scripts/job_scanner/parsers/opentoworkremote.py +53 -0
- package/scripts/job_scanner/parsers/relocate.py +95 -0
- package/scripts/job_scanner/parsers/remote_rocketship.py +90 -0
- package/scripts/job_scanner/parsers/remoteok.py +63 -0
- package/scripts/job_scanner/parsers/remotive.py +57 -0
- package/scripts/job_scanner/parsers/web3_career.py +92 -0
- package/scripts/job_scanner/parsers/wellfound.py +94 -0
- package/scripts/job_scanner/parsers/weworkremotely.py +60 -0
- package/scripts/job_scanner/parsers/ycomb.py +83 -0
- package/scripts/job_scanner/registry.py +65 -0
- package/scripts/job_scanner/scanner.py +162 -0
- package/scripts/question.py +107 -0
- package/scripts/render.py +110 -0
- package/scripts/review.py +89 -0
- package/scripts/setup.py +139 -0
- package/scripts/tailor_brief.py +379 -0
- package/scripts/tailor_cv.py +746 -0
- package/scripts/tailor_prompts.py +101 -0
- package/scripts/tui/__init__.py +1 -0
- package/scripts/tui/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/app.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/app.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/helpers.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/helpers.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/screens.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/screens.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/widgets.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/widgets.cpython-313.pyc +0 -0
- package/scripts/tui/app.py +1547 -0
- package/scripts/tui/helpers.py +65 -0
- package/scripts/tui/screens.py +642 -0
- package/scripts/tui/widgets.py +276 -0
- package/scripts/tui_app.py +25 -0
- package/scripts/wizard.py +109 -0
- package/tui.bat +50 -0
- package/tui.sh +59 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Dynamic configuration for Chameleon — 0 hardcoded values.
|
|
2
|
+
|
|
3
|
+
All settings live in a JSON file next to this module. The TUI can
|
|
4
|
+
read/write every key through the Settings tab. Defaults are created
|
|
5
|
+
on first run.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
CONFIG_DIR = Path(__file__).resolve().parent.parent / ".chameleon"
|
|
17
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
18
|
+
|
|
19
|
+
DEFAULTS: dict[str, Any] = {
|
|
20
|
+
# ── Paths ──────────────────────────────────────────────────────────
|
|
21
|
+
"profile_path": "profiles/github_repos.json",
|
|
22
|
+
"master_cv_path": "templates/my_cv.yaml",
|
|
23
|
+
"templates_dir": "templates",
|
|
24
|
+
"output_dir": "output",
|
|
25
|
+
"scans_dir": "output/scans",
|
|
26
|
+
"analyses_dir": "output/job_analyses",
|
|
27
|
+
"venv_python": ".venv/bin/python3",
|
|
28
|
+
"cache_dir": ".chameleon/cache",
|
|
29
|
+
"db_path": ".chameleon/jobs.db",
|
|
30
|
+
"fonts_dir": ".chameleon/fonts",
|
|
31
|
+
|
|
32
|
+
# ── Scanner defaults ───────────────────────────────────────────────
|
|
33
|
+
"scan_queries": ["python", "rust", "ai engineer"],
|
|
34
|
+
"scan_platforms": "remoteok,remotive,hn_hiring",
|
|
35
|
+
"scan_limit_per_query": 15,
|
|
36
|
+
"scan_timeout_seconds": 45,
|
|
37
|
+
|
|
38
|
+
# ── Matching ───────────────────────────────────────────────────────
|
|
39
|
+
"score_weight_languages": 25,
|
|
40
|
+
"score_weight_frameworks": 25,
|
|
41
|
+
"score_weight_domains": 25,
|
|
42
|
+
"score_weight_cv_alignment": 25,
|
|
43
|
+
"score_threshold_strong": 80,
|
|
44
|
+
"score_threshold_good": 60,
|
|
45
|
+
"score_threshold_moderate": 40,
|
|
46
|
+
"max_matching_repos": 5,
|
|
47
|
+
"max_cv_keywords_in_detail": 5,
|
|
48
|
+
"cv_alignment_min_match_strong": 5,
|
|
49
|
+
"cv_alignment_min_match_good": 3,
|
|
50
|
+
"cv_alignment_min_match_moderate": 1,
|
|
51
|
+
"domain_adjacent_min_strong": 2,
|
|
52
|
+
"domain_adjacent_min_good": 1,
|
|
53
|
+
"lang_overlap_3_score": 25,
|
|
54
|
+
"lang_overlap_2_score": 20,
|
|
55
|
+
"lang_overlap_1_score": 15,
|
|
56
|
+
"lang_related_score": 10,
|
|
57
|
+
"fw_overlap_3_score": 25,
|
|
58
|
+
"fw_overlap_2_score": 20,
|
|
59
|
+
"fw_overlap_1_score": 15,
|
|
60
|
+
"fw_adjacent_score": 8,
|
|
61
|
+
"dom_overlap_2_score": 25,
|
|
62
|
+
"dom_overlap_1_score": 20,
|
|
63
|
+
"dom_adjacent_2_score": 15,
|
|
64
|
+
"dom_adjacent_1_score": 10,
|
|
65
|
+
"cv_match_5_score": 25,
|
|
66
|
+
"cv_match_3_score": 20,
|
|
67
|
+
"cv_match_1_score": 15,
|
|
68
|
+
|
|
69
|
+
# ── Score colors (Textual markup) ──────────────────────────────────
|
|
70
|
+
"color_strong": "bold bright_green",
|
|
71
|
+
"color_good": "bold yellow",
|
|
72
|
+
"color_moderate": "bold dark_orange",
|
|
73
|
+
"color_weak": "bold red",
|
|
74
|
+
|
|
75
|
+
# ── Fonts for CV rendering ─────────────────────────────────────────
|
|
76
|
+
"cv_font_family": "Source Sans Pro",
|
|
77
|
+
"cv_font_size_body": "9.5pt",
|
|
78
|
+
"cv_font_size_headline": "9.5pt",
|
|
79
|
+
"cv_font_size_connections": "9.5pt",
|
|
80
|
+
"cv_line_spacing": "0.8em",
|
|
81
|
+
"cv_entry_spacing": "0.2cm",
|
|
82
|
+
"cv_page_margin_top": "1.0cm",
|
|
83
|
+
"cv_page_margin_bottom": "1.0cm",
|
|
84
|
+
"cv_page_margin_left": "1.5cm",
|
|
85
|
+
"cv_page_margin_right": "1.5cm",
|
|
86
|
+
"cv_primary_color": "#2b3a67",
|
|
87
|
+
"cv_secondary_color": "#3a5a8c",
|
|
88
|
+
|
|
89
|
+
# ── Tailoring ──────────────────────────────────────────────────────
|
|
90
|
+
"summary_max_chars": 300,
|
|
91
|
+
"safe_company_max_chars": 20,
|
|
92
|
+
"safe_title_max_chars": 20,
|
|
93
|
+
"opencode_bin_path": "~/.opencode/bin/opencode",
|
|
94
|
+
|
|
95
|
+
# ── AI Reviewer / Model ────────────────────────────────────────────
|
|
96
|
+
"model_provider": "opencode",
|
|
97
|
+
"model_id": "big-pickle",
|
|
98
|
+
"opencode_api_url": "http://localhost:4096",
|
|
99
|
+
"opencode_server_username": "",
|
|
100
|
+
"opencode_server_password": "",
|
|
101
|
+
|
|
102
|
+
# ── Telegram Bot ───────────────────────────────────────────────────
|
|
103
|
+
"telegram_bot_token": "",
|
|
104
|
+
"telegram_allowed_user_ids": "",
|
|
105
|
+
|
|
106
|
+
# ── WhatsApp / Evolution API ───────────────────────────────────────
|
|
107
|
+
"evolution_api_url": "http://localhost:8080",
|
|
108
|
+
"evolution_api_key": "",
|
|
109
|
+
"whatsapp_instance_name": "chameleon",
|
|
110
|
+
|
|
111
|
+
# ── Bridge ─────────────────────────────────────────────────────────
|
|
112
|
+
"webhook_secret": "change-me-to-a-random-string",
|
|
113
|
+
"rss_scan_interval_minutes": 60,
|
|
114
|
+
"rss_notify_new_jobs": True,
|
|
115
|
+
"rss_notify_high_score": True,
|
|
116
|
+
"rss_high_score_threshold": 70,
|
|
117
|
+
|
|
118
|
+
# ── Setup Wizard ───────────────────────────────────────────────────
|
|
119
|
+
"setup_wizard_completed": False,
|
|
120
|
+
"setup_wizard_version": 1,
|
|
121
|
+
|
|
122
|
+
# ── Cache ──────────────────────────────────────────────────────────
|
|
123
|
+
"cache_ttl_seconds": 3600,
|
|
124
|
+
"max_cached_scans": 50,
|
|
125
|
+
|
|
126
|
+
# ── Rate limiting ──────────────────────────────────────────────────
|
|
127
|
+
"rate_limit_per_platform_per_minute": 10,
|
|
128
|
+
"rate_limit_global_per_minute": 60,
|
|
129
|
+
|
|
130
|
+
# ── Retry ──────────────────────────────────────────────────────────
|
|
131
|
+
"max_retries": 3,
|
|
132
|
+
"retry_backoff_seconds": 2,
|
|
133
|
+
|
|
134
|
+
# ── ATS Ghost Text ────────────────────────────────────────────────
|
|
135
|
+
"ats_ghost_enabled": False,
|
|
136
|
+
"ats_ghost_extra_terms": "",
|
|
137
|
+
"ats_ghost_font_size": "1",
|
|
138
|
+
"ats_ghost_color": "#ffffff",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Domain adjacency (used by matcher)
|
|
142
|
+
DOMAIN_ADJACENCY: dict[str, list[str]] = {
|
|
143
|
+
"healthcare": ["ai/ml", "web-apps", "data-science"],
|
|
144
|
+
"cryptography": ["security", "desktop-apps", "file-management", "blockchain"],
|
|
145
|
+
"ai/ml": ["embeddings", "rag", "web-automation", "data-science"],
|
|
146
|
+
"rag": ["ai/ml", "embeddings", "nlp"],
|
|
147
|
+
"embeddings": ["ai/ml", "rag", "vector-database"],
|
|
148
|
+
"devops": ["infrastructure", "web-automation", "cloud"],
|
|
149
|
+
"fintech": ["web-apps", "api", "ecommerce"],
|
|
150
|
+
"ecommerce": ["web-apps", "marketplace", "fintech"],
|
|
151
|
+
"geospatial": ["mapping", "gis", "data-visualization"],
|
|
152
|
+
"web-automation": ["testing", "devops", "browser"],
|
|
153
|
+
"desktop-apps": ["cross-platform", "gui", "file-management"],
|
|
154
|
+
"file-management": ["storage", "cloud", "desktop-apps"],
|
|
155
|
+
"education": ["web-apps", "content-management"],
|
|
156
|
+
"conservation": ["geospatial", "data-science", "environmental"],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# Language adjacency (used by matcher)
|
|
160
|
+
LANG_ADJACENCY: dict[str, list[str]] = {
|
|
161
|
+
"typescript": ["javascript"],
|
|
162
|
+
"javascript": ["typescript"],
|
|
163
|
+
"vue": ["javascript", "typescript"],
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _resolve(path: str) -> Path:
|
|
168
|
+
base = Path(__file__).resolve().parent.parent
|
|
169
|
+
return (base / path).resolve()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _ensure_dir(p: Path) -> None:
|
|
173
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Config:
|
|
177
|
+
"""Thread-safe config store. Reload by calling config.reload()."""
|
|
178
|
+
|
|
179
|
+
_data: dict[str, Any] = {}
|
|
180
|
+
_loaded = False
|
|
181
|
+
_lock = threading.Lock()
|
|
182
|
+
|
|
183
|
+
def __init__(self) -> None:
|
|
184
|
+
if not Config._loaded:
|
|
185
|
+
Config.reload()
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def reload(cls) -> None:
|
|
189
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
if CONFIG_PATH.exists():
|
|
191
|
+
try:
|
|
192
|
+
raw = json.loads(CONFIG_PATH.read_text())
|
|
193
|
+
cls._data = {**DEFAULTS, **raw}
|
|
194
|
+
except (json.JSONDecodeError, OSError):
|
|
195
|
+
cls._data = dict(DEFAULTS)
|
|
196
|
+
else:
|
|
197
|
+
cls._data = dict(DEFAULTS)
|
|
198
|
+
cls._persist()
|
|
199
|
+
cls._loaded = True
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def _persist(cls) -> None:
|
|
203
|
+
_ensure_dir(CONFIG_PATH)
|
|
204
|
+
try:
|
|
205
|
+
CONFIG_PATH.write_text(json.dumps(cls._data, indent=2))
|
|
206
|
+
except OSError:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def get(cls, key: str, default: Any = None) -> Any:
|
|
211
|
+
return cls._data.get(key, default)
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def set(cls, key: str, value: Any) -> None:
|
|
215
|
+
with cls._lock:
|
|
216
|
+
cls._data[key] = value
|
|
217
|
+
cls._persist()
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def set_nopersist(cls, key: str, value: Any) -> None:
|
|
221
|
+
"""Update in memory only — call persist() to write to disk."""
|
|
222
|
+
with cls._lock:
|
|
223
|
+
cls._data[key] = value
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def persist(cls) -> None:
|
|
227
|
+
"""Write current config to disk."""
|
|
228
|
+
with cls._lock:
|
|
229
|
+
cls._persist()
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def set_many(cls, updates: dict[str, Any]) -> None:
|
|
233
|
+
with cls._lock:
|
|
234
|
+
cls._data.update(updates)
|
|
235
|
+
cls._persist()
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def all(cls) -> dict[str, Any]:
|
|
239
|
+
return dict(cls._data)
|
|
240
|
+
|
|
241
|
+
@classmethod
|
|
242
|
+
def resolve(cls, key: str) -> Path:
|
|
243
|
+
raw = cls.get(key, "")
|
|
244
|
+
if not raw:
|
|
245
|
+
return Path()
|
|
246
|
+
return _resolve(raw)
|
|
247
|
+
|
|
248
|
+
# ── Convenience helpers ────────────────────────────────────────────
|
|
249
|
+
@classmethod
|
|
250
|
+
def profile_path(cls) -> Path:
|
|
251
|
+
return cls.resolve("profile_path")
|
|
252
|
+
|
|
253
|
+
@classmethod
|
|
254
|
+
def master_cv_path(cls) -> Path:
|
|
255
|
+
return cls.resolve("master_cv_path")
|
|
256
|
+
|
|
257
|
+
@classmethod
|
|
258
|
+
def templates_dir(cls) -> Path:
|
|
259
|
+
return cls.resolve("templates_dir")
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
def output_dir(cls) -> Path:
|
|
263
|
+
return cls.resolve("output_dir")
|
|
264
|
+
|
|
265
|
+
@classmethod
|
|
266
|
+
def scans_dir(cls) -> Path:
|
|
267
|
+
return _resolve(cls.get("scans_dir", "output/scans"))
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def analyses_dir(cls) -> Path:
|
|
271
|
+
return _resolve(cls.get("analyses_dir", "output/job_analyses"))
|
|
272
|
+
|
|
273
|
+
@classmethod
|
|
274
|
+
def venv_python(cls) -> Path:
|
|
275
|
+
raw = cls.get("venv_python", ".venv/bin/python3")
|
|
276
|
+
base = Path(__file__).resolve().parent.parent
|
|
277
|
+
# Don't .resolve() through symlinks — venv python is often a symlink
|
|
278
|
+
# to system python, and resolving loses venv site-packages.
|
|
279
|
+
return (base / raw)
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def cache_dir(cls) -> Path:
|
|
283
|
+
return _resolve(cls.get("cache_dir", ".chameleon/cache"))
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def fonts_dir(cls) -> Path:
|
|
287
|
+
return _resolve(cls.get("fonts_dir", ".chameleon/fonts"))
|
|
288
|
+
|
|
289
|
+
@classmethod
|
|
290
|
+
def score_color(cls, score: int) -> str:
|
|
291
|
+
if score >= cls.get("score_threshold_strong", 80):
|
|
292
|
+
return cls.get("color_strong", "bold bright_green")
|
|
293
|
+
elif score >= cls.get("score_threshold_good", 60):
|
|
294
|
+
return cls.get("color_good", "bold yellow")
|
|
295
|
+
elif score >= cls.get("score_threshold_moderate", 40):
|
|
296
|
+
return cls.get("color_moderate", "bold dark_orange")
|
|
297
|
+
return cls.get("color_weak", "bold red")
|
|
298
|
+
|
|
299
|
+
@classmethod
|
|
300
|
+
def scan_queries(cls) -> list[str]:
|
|
301
|
+
return list(cls.get("scan_queries", ["python", "rust", "ai engineer"]))
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def scan_platforms(cls) -> str:
|
|
305
|
+
return cls.get("scan_platforms", "remoteok,remotive,hn_hiring")
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def scan_limit(cls) -> int:
|
|
309
|
+
return cls.get("scan_limit_per_query", 15)
|
|
310
|
+
|
|
311
|
+
@classmethod
|
|
312
|
+
def scan_timeout(cls) -> int:
|
|
313
|
+
return cls.get("scan_timeout_seconds", 45)
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def env(cls) -> dict[str, str]:
|
|
317
|
+
"""Return env dict with PATH extended for subprocess calls."""
|
|
318
|
+
env = os.environ.copy()
|
|
319
|
+
opencode_bin = cls.opencode_bin()
|
|
320
|
+
if opencode_bin:
|
|
321
|
+
parent = str(opencode_bin.parent)
|
|
322
|
+
path = env.get("PATH", "")
|
|
323
|
+
sep = ";" if os.name == "nt" else ":"
|
|
324
|
+
if parent not in path:
|
|
325
|
+
env["PATH"] = f"{parent}{sep}{path}"
|
|
326
|
+
return env
|
|
327
|
+
|
|
328
|
+
@classmethod
|
|
329
|
+
def opencode_bin(cls) -> Path:
|
|
330
|
+
raw = cls.get("opencode_bin_path", "~/.opencode/bin/opencode")
|
|
331
|
+
path = Path(raw).expanduser()
|
|
332
|
+
if not path.is_absolute():
|
|
333
|
+
path = _resolve(raw)
|
|
334
|
+
return path
|
|
335
|
+
|
|
336
|
+
@classmethod
|
|
337
|
+
def domain_adjacency(cls) -> dict[str, list[str]]:
|
|
338
|
+
return dict(DOMAIN_ADJACENCY)
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
def lang_adjacency(cls) -> dict[str, list[str]]:
|
|
342
|
+
return dict(LANG_ADJACENCY)
|
|
343
|
+
|
|
344
|
+
# ── Bridge config sync ─────────────────────────────────────────────
|
|
345
|
+
@classmethod
|
|
346
|
+
def bridge_config_path(cls) -> Path:
|
|
347
|
+
return cls.resolve("db_path").parent / "bridge.json"
|
|
348
|
+
|
|
349
|
+
@classmethod
|
|
350
|
+
def sync_to_bridge(cls) -> None:
|
|
351
|
+
"""Push relevant settings to .chameleon/bridge.json so the bridge
|
|
352
|
+
process picks them up without manual .env editing."""
|
|
353
|
+
bridge_path = cls.bridge_config_path()
|
|
354
|
+
try:
|
|
355
|
+
if bridge_path.exists():
|
|
356
|
+
import json
|
|
357
|
+
bridge = json.loads(bridge_path.read_text())
|
|
358
|
+
else:
|
|
359
|
+
bridge = {}
|
|
360
|
+
bridge.setdefault("telegram", {})
|
|
361
|
+
bridge["telegram"]["bot_token"] = cls.get("telegram_bot_token", "")
|
|
362
|
+
bridge["telegram"]["allowed_user_ids"] = cls.get("telegram_allowed_user_ids", "")
|
|
363
|
+
bridge.setdefault("whatsapp", {})
|
|
364
|
+
bridge["whatsapp"]["evolution_api_url"] = cls.get("evolution_api_url", "http://localhost:8080")
|
|
365
|
+
bridge["whatsapp"]["evolution_api_key"] = cls.get("evolution_api_key", "")
|
|
366
|
+
bridge["whatsapp"]["instance_name"] = cls.get("whatsapp_instance_name", "chameleon")
|
|
367
|
+
bridge.setdefault("chameleon", {})
|
|
368
|
+
bridge["chameleon"]["project_root"] = str(cls.resolve("master_cv_path").parent.parent)
|
|
369
|
+
bridge["chameleon"]["master_cv"] = str(cls.master_cv_path().relative_to(cls.resolve("master_cv_path").parent.parent))
|
|
370
|
+
bridge_path.parent.mkdir(parents=True, exist_ok=True)
|
|
371
|
+
bridge_path.write_text(json.dumps(bridge, indent=2))
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
config = Config()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
9
|
+
|
|
10
|
+
from scripts.config import config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _opencode_run(prompt: str, timeout_s: int = 120) -> tuple[bool, str]:
|
|
14
|
+
opencode_bin = config.opencode_bin()
|
|
15
|
+
if not opencode_bin.exists():
|
|
16
|
+
return False, f"opencode not found at {opencode_bin}"
|
|
17
|
+
|
|
18
|
+
env = {**config.env(), "OPENCODE_CLI_DISABLE_ANIMATION": "true"}
|
|
19
|
+
chameleon_root = Path(__file__).resolve().parent.parent
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
proc = subprocess.Popen(
|
|
23
|
+
[str(opencode_bin), "run", prompt],
|
|
24
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
25
|
+
cwd=str(chameleon_root), env=env,
|
|
26
|
+
)
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
return False, f"opencode binary not executable at {opencode_bin}"
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
out, err = proc.communicate(timeout=timeout_s)
|
|
32
|
+
except subprocess.TimeoutExpired:
|
|
33
|
+
proc.kill()
|
|
34
|
+
proc.wait(timeout=5)
|
|
35
|
+
return False, f"opencode timed out after {timeout_s}s"
|
|
36
|
+
except Exception as e:
|
|
37
|
+
proc.kill()
|
|
38
|
+
proc.wait(timeout=5)
|
|
39
|
+
return False, str(e)
|
|
40
|
+
|
|
41
|
+
if proc.returncode != 0:
|
|
42
|
+
return False, (err or f"opencode exited non-zero ({proc.returncode})").strip()[:200]
|
|
43
|
+
out = (out or "").strip()
|
|
44
|
+
if not out:
|
|
45
|
+
return False, (err or "opencode produced no output").strip()[:200]
|
|
46
|
+
return True, out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
COVER_LETTER_PROMPT = """Write a concise, first-person cover letter for a job application.
|
|
50
|
+
|
|
51
|
+
## Rules
|
|
52
|
+
- Default to exactly 2 paragraphs.
|
|
53
|
+
- Start with "Hey,"
|
|
54
|
+
- End with "Regards," and "David"
|
|
55
|
+
- Write in simple, direct first-person language grounded in the selected resume and job description.
|
|
56
|
+
- Lead with the strongest fit and one concrete impact detail when possible.
|
|
57
|
+
- Do not turn the letter into a stack list, ATS keyword dump, or generic enthusiasm.
|
|
58
|
+
- Do not use markdown formatting in the cover letter text.
|
|
59
|
+
|
|
60
|
+
## JOB DESCRIPTION
|
|
61
|
+
{jd_text}
|
|
62
|
+
|
|
63
|
+
## RESUME YAML
|
|
64
|
+
{cv_yaml}
|
|
65
|
+
|
|
66
|
+
Return ONLY the cover letter text with exactly 2 paragraphs, starting with "Hey," and ending with "Regards,\nDavid".
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def generate_cover_letter(jd_text: str, cv_path: str | Path) -> dict:
|
|
71
|
+
cv_yaml = Path(cv_path).read_text(encoding="utf-8")
|
|
72
|
+
prompt = COVER_LETTER_PROMPT.format(jd_text=jd_text.strip(), cv_yaml=cv_yaml)
|
|
73
|
+
success, result = _opencode_run(prompt)
|
|
74
|
+
if success:
|
|
75
|
+
return {"success": True, "cover_letter": result}
|
|
76
|
+
return {"success": False, "error": result}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main():
|
|
80
|
+
ap = argparse.ArgumentParser(description="Generate a cover letter from JD + CV")
|
|
81
|
+
ap.add_argument("jd_file", help="Path to job description text file")
|
|
82
|
+
ap.add_argument("--cv", "-c", default=None, help="Path to CV YAML (default: master CV from config)")
|
|
83
|
+
ap.add_argument("--company", help="Company name")
|
|
84
|
+
ap.add_argument("--title", help="Role title")
|
|
85
|
+
ap.add_argument("--json", action="store_true", help="JSON output")
|
|
86
|
+
args = ap.parse_args()
|
|
87
|
+
|
|
88
|
+
jd_text = Path(args.jd_file).read_text(encoding="utf-8")
|
|
89
|
+
cv_path = args.cv or config.master_cv_path()
|
|
90
|
+
|
|
91
|
+
result = generate_cover_letter(jd_text, cv_path)
|
|
92
|
+
|
|
93
|
+
if args.json:
|
|
94
|
+
import json
|
|
95
|
+
print(json.dumps(result, indent=2))
|
|
96
|
+
else:
|
|
97
|
+
if result.get("success"):
|
|
98
|
+
print(result["cover_letter"])
|
|
99
|
+
else:
|
|
100
|
+
print(f"Error: {result.get('error', 'unknown')}", file=sys.stderr)
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
main()
|