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
package/requirements.txt
ADDED
package/scan.sh
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# scan.sh — quick job scanner CLI wrapper
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
CHAMELEON_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
5
|
+
cd "$CHAMELEON_DIR"
|
|
6
|
+
|
|
7
|
+
VENV_PYTHON=".venv/bin/python3"
|
|
8
|
+
if [[ ! -f "$VENV_PYTHON" ]]; then
|
|
9
|
+
VENV_PYTHON="python3"
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
exec "$VENV_PYTHON" -m scripts.job_scanner.scanner "$@"
|
package/score.sh
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# score.sh — quick job scoring CLI wrapper
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
CHAMELEON_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
5
|
+
cd "$CHAMELEON_DIR"
|
|
6
|
+
|
|
7
|
+
VENV_PYTHON=".venv/bin/python3"
|
|
8
|
+
if [[ ! -f "$VENV_PYTHON" ]]; then
|
|
9
|
+
VENV_PYTHON="python3"
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
exec "$VENV_PYTHON" -m scripts.job_matcher "$@"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Chameleon scripts package
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ATS Ghost Text — inject invisible keyword text into rendered PDF.
|
|
3
|
+
Uses reportlab to write invisible (white-on-white) text into the
|
|
4
|
+
PDF content stream so ATS parsers extract it while humans see nothing.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from io import BytesIO
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Sequence
|
|
12
|
+
|
|
13
|
+
from pypdf import PdfReader, PdfWriter
|
|
14
|
+
from reportlab.pdfgen import canvas
|
|
15
|
+
from reportlab.lib.units import mm
|
|
16
|
+
from reportlab.lib.pagesizes import letter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def collect_ats_terms(
|
|
20
|
+
jd_text: str,
|
|
21
|
+
extra_terms: str = "",
|
|
22
|
+
existing_keywords: Sequence[str] | None = None,
|
|
23
|
+
max_terms: int = 200,
|
|
24
|
+
) -> list[str]:
|
|
25
|
+
"""Collect all ATS-relevant terms from JD + extras."""
|
|
26
|
+
seen: set[str] = set()
|
|
27
|
+
terms: list[str] = []
|
|
28
|
+
|
|
29
|
+
def add(t: str) -> None:
|
|
30
|
+
t = t.strip().strip(",.;:")
|
|
31
|
+
if t and len(t) > 1 and t.lower() not in seen:
|
|
32
|
+
seen.add(t.lower())
|
|
33
|
+
terms.append(t)
|
|
34
|
+
|
|
35
|
+
if existing_keywords:
|
|
36
|
+
for kw in existing_keywords:
|
|
37
|
+
add(kw)
|
|
38
|
+
|
|
39
|
+
lower = jd_text.lower()
|
|
40
|
+
for m in re.finditer(r"\b[A-Z][a-z]+[A-Z][a-zA-Z]*\b", jd_text):
|
|
41
|
+
add(m.group())
|
|
42
|
+
for m in re.finditer(r"\b[a-z]+[-.][a-z]+\b", lower):
|
|
43
|
+
add(m.group())
|
|
44
|
+
|
|
45
|
+
ats_patterns = [
|
|
46
|
+
r"\b(typescript|javascript|python|rust|go|golang|java|c\+\+|ruby|php|swift|kotlin|scala|r|dart|elixir|haskell|clojure)\b",
|
|
47
|
+
r"\b(vue|react|angular|svelte|next\.?js|nuxt|remix|solid|express|fastapi|flask|django|spring|rails|laravel|asp\.net|node\.js|deno|bun)\b",
|
|
48
|
+
r"\b(pytorch|tensorflow|langchain|crewai|autogen|semantic.?kernel|llama.?index|haystack|spacy|nltk|opencv|hugging.?face|transformers|diffusers)\b",
|
|
49
|
+
r"\b(rag|embedding|vector.?db|pgvector|pinecone|weaviate|chroma|qdrant|milvus|redis|postgresql|mysql|mongodb|dynamodb|cosmos.?db)\b",
|
|
50
|
+
r"\b(docker|kubernetes|k8s|helm|terraform|ansible|jenkins|github.?actions|gitlab.?ci|argocd|prometheus|grafana|datadog|new.?relic)\b",
|
|
51
|
+
r"\b(aws|azure|gcp|cloud|s3|ec2|lambda|cloudflare|vercel|netlify|heroku|digital.?ocean|linode)\b",
|
|
52
|
+
r"\b(tailwind|bootstrap|sass|less|css|html|webpack|vite|esbuild|rollup|babel)\b",
|
|
53
|
+
r"\b(graphql|rest|grpc|websocket|mqtt|kafka|rabbitmq|nats|redis|celery|bull|sidekiq)\b",
|
|
54
|
+
r"\b(pytest|jest|vitest|cypress|playwright|selenium|mocha|chai|rspec|junit|unittest)\b",
|
|
55
|
+
r"\b(ci/cd|devops|mlops|data.?pipeline|etl|elt|data.?warehouse|data.?lake|olap|oltp)\b",
|
|
56
|
+
r"\b(oauth|jwt|saml|oidc|sso|rbac|abac|pci|hipaa|gdpr|soc2|iso27001)\b",
|
|
57
|
+
r"\b(agile|scrum|kanban|sprint|jira|confluence|notion|linear|asana|trello)\b",
|
|
58
|
+
r"\b(microservice|monolith|serverless|edge|distributed.?system|event.?driven|cqrs|event.?sourcing)\b",
|
|
59
|
+
r"\b(fastapi|flask|django|spring.?boot|express|koa|hono|actix|rocket|axum|poem)\b",
|
|
60
|
+
]
|
|
61
|
+
for pat in ats_patterns:
|
|
62
|
+
for m in re.finditer(pat, lower):
|
|
63
|
+
add(m.group())
|
|
64
|
+
|
|
65
|
+
phrases = re.findall(r"\b([A-Z][a-z]+ (?:[A-Z][a-z]+ ){0,2}[A-Z][a-z]+)\b", jd_text)
|
|
66
|
+
for p in phrases:
|
|
67
|
+
if len(p.split()) >= 2:
|
|
68
|
+
add(p)
|
|
69
|
+
|
|
70
|
+
if extra_terms:
|
|
71
|
+
for t in extra_terms.replace(",", " ").split():
|
|
72
|
+
add(t)
|
|
73
|
+
|
|
74
|
+
return terms[:max_terms]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _make_ghost_overlay_page(
|
|
78
|
+
terms: list[str],
|
|
79
|
+
width: float,
|
|
80
|
+
height: float,
|
|
81
|
+
) -> BytesIO:
|
|
82
|
+
"""Create a PDF page as BytesIO with invisible ghost text.
|
|
83
|
+
|
|
84
|
+
The text is drawn with fill opacity 0 and stroke opacity 0,
|
|
85
|
+
so it is completely invisible to the human eye. ATS text
|
|
86
|
+
extractors still see it because it exists in the content stream.
|
|
87
|
+
"""
|
|
88
|
+
buf = BytesIO()
|
|
89
|
+
c = canvas.Canvas(buf, pagesize=(width, height))
|
|
90
|
+
c.setFont("Helvetica", 1)
|
|
91
|
+
# Invisible: white fill + white stroke + zero opacity
|
|
92
|
+
c.setFillColorRGB(1, 1, 1)
|
|
93
|
+
c.setStrokeColorRGB(1, 1, 1)
|
|
94
|
+
c.setFillAlpha(0)
|
|
95
|
+
c.setStrokeAlpha(0)
|
|
96
|
+
|
|
97
|
+
keyword_text = " ".join(terms)
|
|
98
|
+
# Write the keyword block near the bottom of the page
|
|
99
|
+
text_object = c.beginText()
|
|
100
|
+
text_object.setCharSpace(0.5)
|
|
101
|
+
text_object.setTextOrigin(0, 0)
|
|
102
|
+
# Write as a single long line (ATS parsers read this)
|
|
103
|
+
text_object.textLine(keyword_text)
|
|
104
|
+
# Also write a second line with terms reversed
|
|
105
|
+
text_object.textLine(" ".join(reversed(terms)))
|
|
106
|
+
c.drawText(text_object)
|
|
107
|
+
c.showPage()
|
|
108
|
+
c.save()
|
|
109
|
+
buf.seek(0)
|
|
110
|
+
return buf
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def inject_ghost_text(
|
|
114
|
+
pdf_path: Path,
|
|
115
|
+
terms: list[str],
|
|
116
|
+
) -> bool:
|
|
117
|
+
"""Inject invisible keyword text into every page of the PDF.
|
|
118
|
+
|
|
119
|
+
Uses a reportlab-generated overlay page merged into each page's
|
|
120
|
+
content stream. The text is white-on-white with zero opacity,
|
|
121
|
+
completely invisible to humans. ATS parsers extract it because
|
|
122
|
+
it is real text in the content stream.
|
|
123
|
+
"""
|
|
124
|
+
if not pdf_path.exists() or not terms:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
reader = PdfReader(str(pdf_path))
|
|
129
|
+
writer = PdfWriter()
|
|
130
|
+
|
|
131
|
+
for i, page in enumerate(reader.pages):
|
|
132
|
+
mb = page.mediabox
|
|
133
|
+
pw = float(mb.width)
|
|
134
|
+
ph = float(mb.height)
|
|
135
|
+
|
|
136
|
+
# Distribute terms across pages (different order each page)
|
|
137
|
+
chunk_size = max(1, len(terms) // max(len(reader.pages), 1))
|
|
138
|
+
start = (i * chunk_size) % len(terms)
|
|
139
|
+
chunk = terms[start : start + chunk_size] or terms
|
|
140
|
+
|
|
141
|
+
overlay_buf = _make_ghost_overlay_page(chunk, pw, ph)
|
|
142
|
+
overlay = PdfReader(overlay_buf)
|
|
143
|
+
page.merge_page(overlay.pages[0], over=False)
|
|
144
|
+
writer.add_page(page)
|
|
145
|
+
|
|
146
|
+
writer.write(str(pdf_path))
|
|
147
|
+
return True
|
|
148
|
+
except Exception:
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def verify_ats_text(pdf_path: Path, sample_terms: list[str] | None = None) -> tuple[bool, list[str]]:
|
|
153
|
+
"""Verify ghost keywords are extractable from the PDF.
|
|
154
|
+
Returns (found_any, list_of_found_terms).
|
|
155
|
+
"""
|
|
156
|
+
if not pdf_path.exists():
|
|
157
|
+
return False, ["PDF not found"]
|
|
158
|
+
try:
|
|
159
|
+
reader = PdfReader(str(pdf_path))
|
|
160
|
+
full_text = ""
|
|
161
|
+
for page in reader.pages:
|
|
162
|
+
full_text += (page.extract_text() or "") + "\n"
|
|
163
|
+
found: list[str] = []
|
|
164
|
+
check = sample_terms or ["python", "typescript", "docker", "rag", "postgresql", "kubernetes", "react", "vue", "fastapi", "aws"]
|
|
165
|
+
for term in check:
|
|
166
|
+
if term.lower() in full_text.lower():
|
|
167
|
+
found.append(term)
|
|
168
|
+
return (len(found) > 0, found)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
return False, [str(e)]
|
package/scripts/cache.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Simple TTL cache for scan results — improves trustability."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from scripts.config import config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _cache_path(key: str) -> Path:
|
|
12
|
+
d = config.cache_dir()
|
|
13
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in key)
|
|
15
|
+
return d / f"{safe}.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cache_get(key: str) -> list[dict] | None:
|
|
19
|
+
p = _cache_path(key)
|
|
20
|
+
if not p.exists():
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
raw = json.loads(p.read_text())
|
|
24
|
+
age = time.time() - raw.get("ts", 0)
|
|
25
|
+
if age > config.get("cache_ttl_seconds", 3600):
|
|
26
|
+
p.unlink(missing_ok=True)
|
|
27
|
+
return None
|
|
28
|
+
return raw.get("data")
|
|
29
|
+
except (json.JSONDecodeError, OSError):
|
|
30
|
+
p.unlink(missing_ok=True)
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cache_set(key: str, data: list[dict]) -> None:
|
|
35
|
+
p = _cache_path(key)
|
|
36
|
+
try:
|
|
37
|
+
p.write_text(json.dumps({"ts": time.time(), "data": data}))
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cache_clear() -> int:
|
|
43
|
+
d = config.cache_dir()
|
|
44
|
+
if not d.exists():
|
|
45
|
+
return 0
|
|
46
|
+
count = 0
|
|
47
|
+
for f in d.glob("*.json"):
|
|
48
|
+
f.unlink(missing_ok=True)
|
|
49
|
+
count += 1
|
|
50
|
+
return count
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cache_prune() -> int:
|
|
54
|
+
d = config.cache_dir()
|
|
55
|
+
if not d.exists():
|
|
56
|
+
return 0
|
|
57
|
+
max_items = config.get("max_cached_scans", 50)
|
|
58
|
+
files = sorted(d.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True)
|
|
59
|
+
removed = 0
|
|
60
|
+
for f in files[max_items:]:
|
|
61
|
+
f.unlink(missing_ok=True)
|
|
62
|
+
removed += 1
|
|
63
|
+
return removed
|
package/scripts/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chameleon Unified CLI — single entry point for all commands.
|
|
3
|
+
Dispatch: python3 -m scripts.cli tailor/score/scan/render/cover/tui/setup
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _find_python() -> str:
|
|
15
|
+
venv = ROOT / ".venv"
|
|
16
|
+
if sys.platform == "win32":
|
|
17
|
+
cand = venv / "Scripts" / "python.exe"
|
|
18
|
+
else:
|
|
19
|
+
cand = venv / "bin" / "python3"
|
|
20
|
+
if cand.exists():
|
|
21
|
+
return str(cand)
|
|
22
|
+
return sys.executable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _run(module: str, args: list[str]) -> None:
|
|
26
|
+
py = _find_python()
|
|
27
|
+
cmd = [py, "-m", module] + args
|
|
28
|
+
proc = subprocess.run(cmd, cwd=ROOT)
|
|
29
|
+
sys.exit(proc.returncode)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def show_help() -> None:
|
|
33
|
+
print("""
|
|
34
|
+
Chameleon CLI — AI Resume Tailor
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
chameleon tailor <jd_text_or_url_or_file> [options] Tailor CV for a job
|
|
38
|
+
chameleon score <jd_text_or_url_or_file> [options] Score job match
|
|
39
|
+
chameleon scan [options] Scan job platforms
|
|
40
|
+
chameleon cover <jd_text_or_url_or_file> [options] Generate cover letter
|
|
41
|
+
chameleon render <yaml_path> Render YAML to PDF
|
|
42
|
+
chameleon init <pdf_or_yaml_path> Import master CV
|
|
43
|
+
chameleon tui Launch the TUI
|
|
44
|
+
chameleon setup Install dependencies
|
|
45
|
+
chameleon wizard Interactive setup
|
|
46
|
+
chameleon help Show this help
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
chameleon tailor "Senior Rust Engineer at Acme" --company Acme
|
|
50
|
+
chameleon tailor https://jobs.example.com/senior-rust
|
|
51
|
+
chameleon score "Python developer, Django, PostgreSQL..."
|
|
52
|
+
chameleon scan -q "rust engineer" --tier1
|
|
53
|
+
chameleon render templates/david_acme_rust_engineer_cv.yaml
|
|
54
|
+
""")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main() -> None:
|
|
58
|
+
if len(sys.argv) < 2:
|
|
59
|
+
show_help()
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
cmd = sys.argv[1]
|
|
63
|
+
args = sys.argv[2:]
|
|
64
|
+
|
|
65
|
+
dispatch = {
|
|
66
|
+
"tailor": "scripts.tailor_cv",
|
|
67
|
+
"score": "scripts.job_matcher",
|
|
68
|
+
"match": "scripts.job_matcher",
|
|
69
|
+
"scan": "scripts.job_scanner.scanner",
|
|
70
|
+
"scan-jobs": "scripts.job_scanner.scanner",
|
|
71
|
+
"cover": "scripts.cover_letter",
|
|
72
|
+
"cover-letter": "scripts.cover_letter",
|
|
73
|
+
"render": "scripts.render",
|
|
74
|
+
"question": "scripts.question",
|
|
75
|
+
"init": None,
|
|
76
|
+
"init-cv": None,
|
|
77
|
+
"tui": "scripts.tui_app",
|
|
78
|
+
"help": None,
|
|
79
|
+
"--help": None,
|
|
80
|
+
"-h": None,
|
|
81
|
+
"setup": None,
|
|
82
|
+
"install": None,
|
|
83
|
+
"wizard": None,
|
|
84
|
+
"test": None,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module = dispatch.get(cmd)
|
|
88
|
+
|
|
89
|
+
if cmd in ("help", "--help", "-h"):
|
|
90
|
+
show_help()
|
|
91
|
+
elif cmd in ("setup", "install"):
|
|
92
|
+
from scripts.setup import run_setup
|
|
93
|
+
run_setup()
|
|
94
|
+
elif cmd == "wizard":
|
|
95
|
+
_run("scripts.wizard", args)
|
|
96
|
+
elif cmd == "test":
|
|
97
|
+
_run("pytest", ["tests/", "-v", "--tb=short"] + args)
|
|
98
|
+
elif module:
|
|
99
|
+
_run(module, args)
|
|
100
|
+
elif cmd in ("init", "init-cv"):
|
|
101
|
+
print("[*] CV initialization requires an AI assistant.\n opencode\n /init-cv", *args)
|
|
102
|
+
else:
|
|
103
|
+
print(f"Unknown command: {cmd}")
|
|
104
|
+
show_help()
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|