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.
Files changed (151) hide show
  1. package/AGENTS.md +186 -0
  2. package/LICENSE +21 -0
  3. package/Makefile +136 -0
  4. package/README.md +356 -0
  5. package/bin/chameleon.js +136 -0
  6. package/chameleon.bat +286 -0
  7. package/chameleon.sh +353 -0
  8. package/package.json +39 -0
  9. package/profiles/github_repos.json +1266 -0
  10. package/requirements.txt +5 -0
  11. package/scan.sh +12 -0
  12. package/score.sh +12 -0
  13. package/scripts/__init__.py +1 -0
  14. package/scripts/__main_tui__.py +11 -0
  15. package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  17. package/scripts/__pycache__/__main_tui__.cpython-313.pyc +0 -0
  18. package/scripts/__pycache__/ats_ghost.cpython-313.pyc +0 -0
  19. package/scripts/__pycache__/cache.cpython-313.pyc +0 -0
  20. package/scripts/__pycache__/config.cpython-312.pyc +0 -0
  21. package/scripts/__pycache__/config.cpython-313.pyc +0 -0
  22. package/scripts/__pycache__/cover_letter.cpython-313.pyc +0 -0
  23. package/scripts/__pycache__/db.cpython-312.pyc +0 -0
  24. package/scripts/__pycache__/db.cpython-313.pyc +0 -0
  25. package/scripts/__pycache__/fonts.cpython-312.pyc +0 -0
  26. package/scripts/__pycache__/fonts.cpython-313.pyc +0 -0
  27. package/scripts/__pycache__/ghost.cpython-313.pyc +0 -0
  28. package/scripts/__pycache__/job_matcher.cpython-312.pyc +0 -0
  29. package/scripts/__pycache__/job_matcher.cpython-313.pyc +0 -0
  30. package/scripts/__pycache__/question.cpython-313.pyc +0 -0
  31. package/scripts/__pycache__/render.cpython-313.pyc +0 -0
  32. package/scripts/__pycache__/review.cpython-313.pyc +0 -0
  33. package/scripts/__pycache__/setup.cpython-312.pyc +0 -0
  34. package/scripts/__pycache__/tailor_brief.cpython-312.pyc +0 -0
  35. package/scripts/__pycache__/tailor_brief.cpython-313.pyc +0 -0
  36. package/scripts/__pycache__/tailor_cv.cpython-312.pyc +0 -0
  37. package/scripts/__pycache__/tailor_cv.cpython-313.pyc +0 -0
  38. package/scripts/__pycache__/tailor_prompts.cpython-312.pyc +0 -0
  39. package/scripts/__pycache__/tailor_prompts.cpython-313.pyc +0 -0
  40. package/scripts/__pycache__/tui_app.cpython-312.pyc +0 -0
  41. package/scripts/__pycache__/tui_app.cpython-313.pyc +0 -0
  42. package/scripts/ats_ghost.py +170 -0
  43. package/scripts/cache.py +63 -0
  44. package/scripts/cli.py +109 -0
  45. package/scripts/config.py +376 -0
  46. package/scripts/cover_letter.py +105 -0
  47. package/scripts/db.py +331 -0
  48. package/scripts/fonts.py +186 -0
  49. package/scripts/ghost.py +88 -0
  50. package/scripts/job_matcher.py +636 -0
  51. package/scripts/job_scanner/__init__.py +8 -0
  52. package/scripts/job_scanner/__main__.py +6 -0
  53. package/scripts/job_scanner/__pycache__/__init__.cpython-312.pyc +0 -0
  54. package/scripts/job_scanner/__pycache__/__init__.cpython-313.pyc +0 -0
  55. package/scripts/job_scanner/__pycache__/__main__.cpython-312.pyc +0 -0
  56. package/scripts/job_scanner/__pycache__/__main__.cpython-313.pyc +0 -0
  57. package/scripts/job_scanner/__pycache__/base.cpython-312.pyc +0 -0
  58. package/scripts/job_scanner/__pycache__/base.cpython-313.pyc +0 -0
  59. package/scripts/job_scanner/__pycache__/browser.cpython-312.pyc +0 -0
  60. package/scripts/job_scanner/__pycache__/browser.cpython-313.pyc +0 -0
  61. package/scripts/job_scanner/__pycache__/cli.cpython-313.pyc +0 -0
  62. package/scripts/job_scanner/__pycache__/registry.cpython-312.pyc +0 -0
  63. package/scripts/job_scanner/__pycache__/registry.cpython-313.pyc +0 -0
  64. package/scripts/job_scanner/__pycache__/scanner.cpython-312.pyc +0 -0
  65. package/scripts/job_scanner/__pycache__/scanner.cpython-313.pyc +0 -0
  66. package/scripts/job_scanner/base.py +197 -0
  67. package/scripts/job_scanner/browser.py +244 -0
  68. package/scripts/job_scanner/cli.py +6 -0
  69. package/scripts/job_scanner/parsers/__init__.py +24 -0
  70. package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-312.pyc +0 -0
  71. package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-313.pyc +0 -0
  72. package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-312.pyc +0 -0
  73. package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-313.pyc +0 -0
  74. package/scripts/job_scanner/parsers/__pycache__/arc.cpython-312.pyc +0 -0
  75. package/scripts/job_scanner/parsers/__pycache__/arc.cpython-313.pyc +0 -0
  76. package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-312.pyc +0 -0
  77. package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-313.pyc +0 -0
  78. package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-312.pyc +0 -0
  79. package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-313.pyc +0 -0
  80. package/scripts/job_scanner/parsers/__pycache__/flexjobs.cpython-313.pyc +0 -0
  81. package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-312.pyc +0 -0
  82. package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-313.pyc +0 -0
  83. package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-312.pyc +0 -0
  84. package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-313.pyc +0 -0
  85. package/scripts/job_scanner/parsers/__pycache__/indeed.cpython-313.pyc +0 -0
  86. package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-312.pyc +0 -0
  87. package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-313.pyc +0 -0
  88. package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-312.pyc +0 -0
  89. package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-313.pyc +0 -0
  90. package/scripts/job_scanner/parsers/__pycache__/linkedin.cpython-313.pyc +0 -0
  91. package/scripts/job_scanner/parsers/__pycache__/opentoworkremote.cpython-313.pyc +0 -0
  92. package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-312.pyc +0 -0
  93. package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-313.pyc +0 -0
  94. package/scripts/job_scanner/parsers/__pycache__/remote_rocketship.cpython-313.pyc +0 -0
  95. package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-312.pyc +0 -0
  96. package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-313.pyc +0 -0
  97. package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-312.pyc +0 -0
  98. package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-313.pyc +0 -0
  99. package/scripts/job_scanner/parsers/__pycache__/web3_career.cpython-313.pyc +0 -0
  100. package/scripts/job_scanner/parsers/__pycache__/wellfound.cpython-313.pyc +0 -0
  101. package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-312.pyc +0 -0
  102. package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-313.pyc +0 -0
  103. package/scripts/job_scanner/parsers/__pycache__/ycomb.cpython-313.pyc +0 -0
  104. package/scripts/job_scanner/parsers/adzuna.py +81 -0
  105. package/scripts/job_scanner/parsers/arc.py +65 -0
  106. package/scripts/job_scanner/parsers/crypto_careers.py +88 -0
  107. package/scripts/job_scanner/parsers/dailyremote.py +91 -0
  108. package/scripts/job_scanner/parsers/flexjobs.py +91 -0
  109. package/scripts/job_scanner/parsers/himalayas.py +47 -0
  110. package/scripts/job_scanner/parsers/hn_hiring.py +94 -0
  111. package/scripts/job_scanner/parsers/indeed.py +98 -0
  112. package/scripts/job_scanner/parsers/jobicy.py +60 -0
  113. package/scripts/job_scanner/parsers/jooble.py +68 -0
  114. package/scripts/job_scanner/parsers/linkedin.py +140 -0
  115. package/scripts/job_scanner/parsers/opentoworkremote.py +53 -0
  116. package/scripts/job_scanner/parsers/relocate.py +95 -0
  117. package/scripts/job_scanner/parsers/remote_rocketship.py +90 -0
  118. package/scripts/job_scanner/parsers/remoteok.py +63 -0
  119. package/scripts/job_scanner/parsers/remotive.py +57 -0
  120. package/scripts/job_scanner/parsers/web3_career.py +92 -0
  121. package/scripts/job_scanner/parsers/wellfound.py +94 -0
  122. package/scripts/job_scanner/parsers/weworkremotely.py +60 -0
  123. package/scripts/job_scanner/parsers/ycomb.py +83 -0
  124. package/scripts/job_scanner/registry.py +65 -0
  125. package/scripts/job_scanner/scanner.py +162 -0
  126. package/scripts/question.py +107 -0
  127. package/scripts/render.py +110 -0
  128. package/scripts/review.py +89 -0
  129. package/scripts/setup.py +139 -0
  130. package/scripts/tailor_brief.py +379 -0
  131. package/scripts/tailor_cv.py +746 -0
  132. package/scripts/tailor_prompts.py +101 -0
  133. package/scripts/tui/__init__.py +1 -0
  134. package/scripts/tui/__pycache__/__init__.cpython-312.pyc +0 -0
  135. package/scripts/tui/__pycache__/__init__.cpython-313.pyc +0 -0
  136. package/scripts/tui/__pycache__/app.cpython-312.pyc +0 -0
  137. package/scripts/tui/__pycache__/app.cpython-313.pyc +0 -0
  138. package/scripts/tui/__pycache__/helpers.cpython-312.pyc +0 -0
  139. package/scripts/tui/__pycache__/helpers.cpython-313.pyc +0 -0
  140. package/scripts/tui/__pycache__/screens.cpython-312.pyc +0 -0
  141. package/scripts/tui/__pycache__/screens.cpython-313.pyc +0 -0
  142. package/scripts/tui/__pycache__/widgets.cpython-312.pyc +0 -0
  143. package/scripts/tui/__pycache__/widgets.cpython-313.pyc +0 -0
  144. package/scripts/tui/app.py +1547 -0
  145. package/scripts/tui/helpers.py +65 -0
  146. package/scripts/tui/screens.py +642 -0
  147. package/scripts/tui/widgets.py +276 -0
  148. package/scripts/tui_app.py +25 -0
  149. package/scripts/wizard.py +109 -0
  150. package/tui.bat +50 -0
  151. 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()