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,5 @@
1
+ PyYAML>=6.0
2
+ httpx>=0.27.0
3
+ pypdf>=4.0
4
+ reportlab>=4.0
5
+ textual>=1.0.0
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
@@ -0,0 +1,11 @@
1
+ """Entry point for: python3 -m scripts.tui_app"""
2
+ from scripts.tui.app import ChameleonTUI
3
+
4
+
5
+ def main():
6
+ app = ChameleonTUI()
7
+ app.run()
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -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)]
@@ -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()