@jsonresume/jobs 0.7.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/README.md +270 -0
- package/bin/cli.js +380 -0
- package/package.json +47 -0
- package/skills/jsonresume-hunt/SKILL.md +208 -0
- package/src/api.js +61 -0
- package/src/auth.js +115 -0
- package/src/cache.js +48 -0
- package/src/export.js +65 -0
- package/src/filters.js +59 -0
- package/src/formatters.js +71 -0
- package/src/tui/AIPanel.js +37 -0
- package/src/tui/App.js +395 -0
- package/src/tui/FilterManager.js +263 -0
- package/src/tui/Header.js +87 -0
- package/src/tui/HelpModal.js +94 -0
- package/src/tui/JobDetail.js +170 -0
- package/src/tui/JobList.js +382 -0
- package/src/tui/PreviewPane.js +68 -0
- package/src/tui/SearchManager.js +207 -0
- package/src/tui/StatusBar.js +108 -0
- package/src/tui/Toast.js +56 -0
- package/src/tui/h.js +2 -0
- package/src/tui/useAI.js +107 -0
- package/src/tui/useJobs.js +168 -0
- package/src/tui/useSearches.js +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# @jsonresume/job-search
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@jsonresume/job-search)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
|
|
7
|
+
Search Hacker News "Who is Hiring" jobs matched against your [JSON Resume](https://jsonresume.org). Jobs are semantically ranked using AI embeddings — your resume is compared against hundreds of monthly job postings to surface the best fits.
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @jsonresume/jobs
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That's it. The CLI walks you through login on first run — all you need is a resume hosted at [registry.jsonresume.org](https://registry.jsonresume.org).
|
|
16
|
+
|
|
17
|
+
## Prerequisites
|
|
18
|
+
|
|
19
|
+
- **Node.js** >= 18
|
|
20
|
+
- A JSON Resume on the [JSON Resume Registry](https://registry.jsonresume.org) (free, backed by your GitHub Gist)
|
|
21
|
+
- *(Optional)* `OPENAI_API_KEY` — enables AI summaries and batch ranking in the TUI
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Run directly with npx (no install needed):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx @jsonresume/jobs
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or install globally:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install -g @jsonresume/job-search
|
|
35
|
+
jsonresume-jobs
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Authentication
|
|
39
|
+
|
|
40
|
+
On first run, the CLI prompts for your GitHub username, verifies your resume exists on the registry, generates an API key, and saves it to `~/.jsonresume/config.json`. Future runs skip straight to the TUI.
|
|
41
|
+
|
|
42
|
+
You can also authenticate manually:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Via environment variable
|
|
46
|
+
export JSONRESUME_API_KEY=jr_yourname_xxxxx
|
|
47
|
+
|
|
48
|
+
# Generate a key via curl
|
|
49
|
+
curl -s -X POST https://registry.jsonresume.org/api/v1/keys \
|
|
50
|
+
-H 'Content-Type: application/json' \
|
|
51
|
+
-d '{"username":"YOUR_GITHUB_USERNAME"}'
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
To clear saved credentials:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx @jsonresume/jobs logout
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Interactive TUI
|
|
61
|
+
|
|
62
|
+
The default command launches a full terminal interface for browsing and managing jobs.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx @jsonresume/jobs
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Features
|
|
69
|
+
|
|
70
|
+
- **Split-pane detail view** — press Enter to see a compact job list on the left and full details on the right, navigate jobs without leaving the detail panel
|
|
71
|
+
- **Tab-based views** — All / Interested / Applied / Maybe / Passed with live counts
|
|
72
|
+
- **Persistent filters** — remote, salary, keyword, days — saved to disk per search profile
|
|
73
|
+
- **Custom search profiles** — targeted searches like "remote React jobs in climate tech" with AI-powered reranking
|
|
74
|
+
- **Two-pass loading** — results appear instantly from vector search, then reshuffle when LLM reranking completes in the background
|
|
75
|
+
- **Batch operations** — select multiple jobs with `v`, then bulk-mark them all at once
|
|
76
|
+
- **Inline search** — press `n` to quickly filter visible jobs by keyword
|
|
77
|
+
- **Export** — press `e` to export your shortlist to a markdown file
|
|
78
|
+
- **Toast notifications** — instant feedback on every action
|
|
79
|
+
- **Help modal** — press `?` for a full keyboard reference
|
|
80
|
+
- **AI summaries** — per-job summaries and batch ranking (requires `OPENAI_API_KEY`)
|
|
81
|
+
- **Vim-style navigation** — `j`/`k`, `g`/`G`, `Ctrl+U`/`Ctrl+D`, `/` for search profiles, `f` for filters
|
|
82
|
+
|
|
83
|
+
### Keyboard Shortcuts
|
|
84
|
+
|
|
85
|
+
#### List View
|
|
86
|
+
|
|
87
|
+
| Key | Action |
|
|
88
|
+
|-----|--------|
|
|
89
|
+
| `j` / `↓` | Move down |
|
|
90
|
+
| `k` / `↑` | Move up |
|
|
91
|
+
| `g` / `G` | Jump to first / last job |
|
|
92
|
+
| `Ctrl+U` / `Ctrl+D` | Page up / page down |
|
|
93
|
+
| `Enter` | Open split-pane detail view |
|
|
94
|
+
| `i` | Mark interested |
|
|
95
|
+
| `x` | Mark applied |
|
|
96
|
+
| `m` | Mark maybe |
|
|
97
|
+
| `p` | Mark passed |
|
|
98
|
+
| `v` | Toggle batch selection |
|
|
99
|
+
| `Tab` / `Shift+Tab` | Next / previous tab |
|
|
100
|
+
| `n` | Inline keyword search |
|
|
101
|
+
| `f` | Manage filters |
|
|
102
|
+
| `/` | Search profiles |
|
|
103
|
+
| `Space` | AI summary |
|
|
104
|
+
| `S` | AI batch review |
|
|
105
|
+
| `e` | Export shortlist to markdown |
|
|
106
|
+
| `R` | Force refresh |
|
|
107
|
+
| `?` | Help modal |
|
|
108
|
+
| `q` | Quit |
|
|
109
|
+
|
|
110
|
+
#### Detail View (Split Pane)
|
|
111
|
+
|
|
112
|
+
| Key | Action |
|
|
113
|
+
|-----|--------|
|
|
114
|
+
| `j` / `k` | Navigate jobs (updates detail pane) |
|
|
115
|
+
| `J` / `K` | Scroll detail content |
|
|
116
|
+
| `o` | Open HN post in browser |
|
|
117
|
+
| `i` / `x` / `m` / `p` | Mark job state |
|
|
118
|
+
| `Space` | AI summary |
|
|
119
|
+
| `Esc` / `q` | Back to full list |
|
|
120
|
+
|
|
121
|
+
#### Filters & Search Profiles
|
|
122
|
+
|
|
123
|
+
| Key | Action |
|
|
124
|
+
|-----|--------|
|
|
125
|
+
| `a` | Add filter |
|
|
126
|
+
| `d` | Delete filter / search profile |
|
|
127
|
+
| `n` | New search profile |
|
|
128
|
+
| `Enter` | Select / edit |
|
|
129
|
+
| `Esc` | Close |
|
|
130
|
+
|
|
131
|
+
## CLI Commands
|
|
132
|
+
|
|
133
|
+
For scripting and pipelines, the CLI also supports direct commands:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npx @jsonresume/jobs search # Find matching jobs
|
|
137
|
+
npx @jsonresume/jobs search --remote --min-salary 150 # Remote, $150k+ salary
|
|
138
|
+
npx @jsonresume/jobs search --search "rust" # Keyword filter
|
|
139
|
+
npx @jsonresume/jobs detail 181420 # Full job details
|
|
140
|
+
npx @jsonresume/jobs mark 181420 interested # Mark a job
|
|
141
|
+
npx @jsonresume/jobs me # Your resume summary
|
|
142
|
+
npx @jsonresume/jobs update ./resume.json # Update your resume
|
|
143
|
+
npx @jsonresume/jobs help # All options
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Command Reference
|
|
147
|
+
|
|
148
|
+
| Command | Description |
|
|
149
|
+
|---------|-------------|
|
|
150
|
+
| *(default)* | Launch interactive TUI |
|
|
151
|
+
| `search` | Find matching jobs (table output) |
|
|
152
|
+
| `detail <id>` | Show full details for a job |
|
|
153
|
+
| `mark <id> <state>` | Set job state: `interested`, `not_interested`, `applied`, `maybe`, `dismissed` |
|
|
154
|
+
| `me` | Show your resume summary |
|
|
155
|
+
| `update <file>` | Upload a new version of your resume |
|
|
156
|
+
| `logout` | Remove saved API key |
|
|
157
|
+
| `help` | Show help |
|
|
158
|
+
|
|
159
|
+
### Search Options
|
|
160
|
+
|
|
161
|
+
| Flag | Description |
|
|
162
|
+
|------|-------------|
|
|
163
|
+
| `--top N` | Number of results (default: 20, max: 100) |
|
|
164
|
+
| `--days N` | How far back to look (default: 30) |
|
|
165
|
+
| `--remote` | Remote jobs only |
|
|
166
|
+
| `--min-salary N` | Minimum salary in thousands (e.g. `150` = $150k) |
|
|
167
|
+
| `--search TERM` | Keyword filter (title, company, skills) |
|
|
168
|
+
| `--interested` | Show only jobs marked interested |
|
|
169
|
+
| `--applied` | Show only jobs marked applied |
|
|
170
|
+
| `--json` | Output raw JSON for piping |
|
|
171
|
+
|
|
172
|
+
### Mark States
|
|
173
|
+
|
|
174
|
+
| State | Icon | Meaning |
|
|
175
|
+
|-------|------|---------|
|
|
176
|
+
| `interested` | ⭐ | You want this job |
|
|
177
|
+
| `applied` | 📨 | You've applied |
|
|
178
|
+
| `maybe` | ? | Considering it |
|
|
179
|
+
| `not_interested` | ✗ | Not for you |
|
|
180
|
+
| `dismissed` | 👁 | Hide from results |
|
|
181
|
+
|
|
182
|
+
## How Ranking Works
|
|
183
|
+
|
|
184
|
+
The system uses a five-stage pipeline to match and rank jobs against your resume.
|
|
185
|
+
|
|
186
|
+
### Stage 1: Embedding Generation
|
|
187
|
+
|
|
188
|
+
Your JSON Resume is fetched from [registry.jsonresume.org](https://registry.jsonresume.org) and converted to text (label, summary, skills, work history). This text is embedded using OpenAI's `text-embedding-3-large` model into a 3072-dimensional vector.
|
|
189
|
+
|
|
190
|
+
Job postings from HN's monthly "Who is Hiring?" threads are parsed by GPT into structured data (title, company, skills, salary, remote, location) and embedded into the same vector space.
|
|
191
|
+
|
|
192
|
+
### Stage 2: Vector Similarity Search
|
|
193
|
+
|
|
194
|
+
Your resume embedding is compared against all job embeddings using cosine similarity via [pgvector](https://github.com/pgvector/pgvector). The top ~500 candidates are retrieved in ~200ms. This is purely semantic — it finds jobs that "sound like" your resume. Jobs you've already passed on are excluded server-side so you always get fresh results.
|
|
195
|
+
|
|
196
|
+
### Stage 3: Custom Search Profiles
|
|
197
|
+
|
|
198
|
+
When you create a search profile (e.g. "remote React roles at climate tech startups"), two techniques boost the prompt's influence:
|
|
199
|
+
|
|
200
|
+
**HyDE (Hypothetical Document Embedding):** Instead of naively blending your prompt into resume text, the system generates a hypothetical ideal job posting matching your preferences. This creates a document-to-document comparison, which is far more effective than query-to-document matching.
|
|
201
|
+
|
|
202
|
+
**Embedding Interpolation:** The resume and HyDE vectors are combined: `0.65 × hyde + 0.35 × resume`. This gives your search intent 65% influence on ranking, versus plain resume matching where the resume dominates ~80% of the signal.
|
|
203
|
+
|
|
204
|
+
### Stage 4: LLM Reranking
|
|
205
|
+
|
|
206
|
+
For custom searches, the top 30 vector results are re-scored by `gpt-4.1-mini`. Each job receives a 1–10 relevance score considering skill alignment, experience level, location fit, and your stated preferences.
|
|
207
|
+
|
|
208
|
+
The final score blends both signals: `0.4 × vector_score + 0.6 × llm_score`. This lets the LLM override semantic similarity — a job that's a great vector match but contradicts your preferences gets pushed down.
|
|
209
|
+
|
|
210
|
+
In the TUI, this runs as a two-pass load: jobs appear instantly from vector search, then reshuffle when reranking finishes in the background.
|
|
211
|
+
|
|
212
|
+
### Stage 5: Client-side Filtering
|
|
213
|
+
|
|
214
|
+
After server-side ranking, the TUI applies local filters (remote only, minimum salary, keyword, days). Filters are persisted per search profile, so switching profiles restores each one's filters.
|
|
215
|
+
|
|
216
|
+
## Claude Code Skill
|
|
217
|
+
|
|
218
|
+
This package includes a [Claude Code skill](https://docs.anthropic.com/en/docs/claude-code/skills) that turns job searching into a guided, AI-assisted experience.
|
|
219
|
+
|
|
220
|
+
### Install
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
mkdir -p ~/.claude/skills/jsonresume-hunt
|
|
224
|
+
cp node_modules/@jsonresume/job-search/skills/jsonresume-hunt/SKILL.md \
|
|
225
|
+
~/.claude/skills/jsonresume-hunt/SKILL.md
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Use
|
|
229
|
+
|
|
230
|
+
In Claude Code, type:
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
/jsonresume-hunt
|
|
234
|
+
/jsonresume-hunt remote React jobs over $150k
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The skill interviews you about what you're looking for, runs multiple searches, researches top companies, does skill gap analysis, collects your decisions, and generates a markdown tracker with your shortlist and outreach drafts.
|
|
238
|
+
|
|
239
|
+
## Environment Variables
|
|
240
|
+
|
|
241
|
+
| Variable | Required | Description |
|
|
242
|
+
|----------|----------|-------------|
|
|
243
|
+
| `JSONRESUME_API_KEY` | No | API key (auto-generated on first run if not set) |
|
|
244
|
+
| `OPENAI_API_KEY` | No | Enables AI summaries and batch ranking in TUI |
|
|
245
|
+
| `JSONRESUME_BASE_URL` | No | API base URL override (default: `https://registry.jsonresume.org`) |
|
|
246
|
+
|
|
247
|
+
## Data Storage
|
|
248
|
+
|
|
249
|
+
| Path | Contents |
|
|
250
|
+
|------|----------|
|
|
251
|
+
| `~/.jsonresume/config.json` | API key and username |
|
|
252
|
+
| `~/.jsonresume/filters.json` | Saved filter presets per search profile |
|
|
253
|
+
| `~/.jsonresume/cache/` | Cached job results (auto-expires) |
|
|
254
|
+
|
|
255
|
+
## Contributing
|
|
256
|
+
|
|
257
|
+
This package is part of the [jsonresume.org](https://github.com/jsonresume/jsonresume.org) monorepo.
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
git clone https://github.com/jsonresume/jsonresume.org.git
|
|
261
|
+
cd jsonresume.org
|
|
262
|
+
pnpm install
|
|
263
|
+
node packages/job-search/bin/cli.js help
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
See the repo root [CLAUDE.md](../../CLAUDE.md) for code standards and contribution guidelines.
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
[MIT](./LICENSE)
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @jsonresume/job-search — Search HN jobs matched to your JSON Resume
|
|
5
|
+
*
|
|
6
|
+
* Just run: npx @jsonresume/jobs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const VERSION = '0.7.0';
|
|
10
|
+
|
|
11
|
+
const BASE_URL =
|
|
12
|
+
getArg('--base-url') ||
|
|
13
|
+
process.env.JSONRESUME_BASE_URL ||
|
|
14
|
+
'https://registry.jsonresume.org';
|
|
15
|
+
|
|
16
|
+
// ── Arg parsing ────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function getArg(name) {
|
|
19
|
+
const idx = process.argv.indexOf(name);
|
|
20
|
+
if (idx === -1) return null;
|
|
21
|
+
return process.argv[idx + 1];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hasFlag(name) {
|
|
25
|
+
return process.argv.includes(name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── API client ─────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
let _apiKey;
|
|
31
|
+
|
|
32
|
+
async function getApiKey() {
|
|
33
|
+
if (_apiKey) return _apiKey;
|
|
34
|
+
const { ensureApiKey } = await import('../src/auth.js');
|
|
35
|
+
_apiKey = await ensureApiKey(BASE_URL);
|
|
36
|
+
return _apiKey;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function api(path, options = {}) {
|
|
40
|
+
const apiKey = await getApiKey();
|
|
41
|
+
const url = `${BASE_URL}/api/v1${path}`;
|
|
42
|
+
const res = await fetch(url, {
|
|
43
|
+
...options,
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${apiKey}`,
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
...options.headers,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
console.error(`Error: ${data.error || res.statusText}`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
return data;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Formatters ─────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function formatSalary(salary, salaryUsd) {
|
|
62
|
+
if (salaryUsd) return `$${Math.round(salaryUsd / 1000)}k`;
|
|
63
|
+
if (salary) return salary;
|
|
64
|
+
return '—';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatLocation(loc, remote) {
|
|
68
|
+
const parts = [];
|
|
69
|
+
if (loc?.city) parts.push(loc.city);
|
|
70
|
+
if (loc?.countryCode) parts.push(loc.countryCode);
|
|
71
|
+
if (remote) parts.push(`(${remote})`);
|
|
72
|
+
return parts.join(', ') || '—';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stateIcon(state) {
|
|
76
|
+
const icons = {
|
|
77
|
+
interested: '⭐',
|
|
78
|
+
applied: '📨',
|
|
79
|
+
not_interested: '✗',
|
|
80
|
+
dismissed: '👁',
|
|
81
|
+
maybe: '?',
|
|
82
|
+
};
|
|
83
|
+
return icons[state] || ' ';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Commands ───────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
async function cmdSearch() {
|
|
89
|
+
const params = new URLSearchParams();
|
|
90
|
+
params.set('top', getArg('--top') || '20');
|
|
91
|
+
params.set('days', getArg('--days') || '30');
|
|
92
|
+
if (hasFlag('--remote')) params.set('remote', 'true');
|
|
93
|
+
if (getArg('--min-salary')) params.set('min_salary', getArg('--min-salary'));
|
|
94
|
+
if (getArg('--search')) params.set('search', getArg('--search'));
|
|
95
|
+
|
|
96
|
+
const { jobs } = await api(`/jobs?${params}`);
|
|
97
|
+
|
|
98
|
+
let filtered = jobs;
|
|
99
|
+
if (hasFlag('--interested'))
|
|
100
|
+
filtered = jobs.filter((j) => j.state === 'interested');
|
|
101
|
+
if (hasFlag('--applied'))
|
|
102
|
+
filtered = jobs.filter((j) => j.state === 'applied');
|
|
103
|
+
|
|
104
|
+
if (hasFlag('--json')) {
|
|
105
|
+
console.log(JSON.stringify(filtered, null, 2));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (filtered.length === 0) {
|
|
110
|
+
console.log('No matching jobs found.');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(
|
|
115
|
+
`\n Found ${filtered.length} matching jobs (last ${params.get(
|
|
116
|
+
'days'
|
|
117
|
+
)} days)\n`
|
|
118
|
+
);
|
|
119
|
+
console.log(
|
|
120
|
+
' ' +
|
|
121
|
+
'St'.padEnd(3) +
|
|
122
|
+
'Score'.padEnd(7) +
|
|
123
|
+
'ID'.padEnd(8) +
|
|
124
|
+
'Title'.padEnd(35) +
|
|
125
|
+
'Company'.padEnd(25) +
|
|
126
|
+
'Location'.padEnd(25) +
|
|
127
|
+
'Salary'.padEnd(10)
|
|
128
|
+
);
|
|
129
|
+
console.log(' ' + '─'.repeat(112));
|
|
130
|
+
|
|
131
|
+
for (const j of filtered) {
|
|
132
|
+
const line =
|
|
133
|
+
' ' +
|
|
134
|
+
stateIcon(j.state).padEnd(3) +
|
|
135
|
+
String(j.similarity).padEnd(7) +
|
|
136
|
+
String(j.id).padEnd(8) +
|
|
137
|
+
(j.title || '').slice(0, 33).padEnd(35) +
|
|
138
|
+
(j.company || '').slice(0, 23).padEnd(25) +
|
|
139
|
+
formatLocation(j.location, j.remote).slice(0, 23).padEnd(25) +
|
|
140
|
+
formatSalary(j.salary, j.salary_usd).padEnd(10);
|
|
141
|
+
console.log(line);
|
|
142
|
+
}
|
|
143
|
+
console.log('');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function cmdDetail() {
|
|
147
|
+
const id = process.argv[3];
|
|
148
|
+
if (!id) {
|
|
149
|
+
console.error('Usage: jsonresume-jobs detail <job_id>');
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const job = await api(`/jobs/${id}`);
|
|
154
|
+
|
|
155
|
+
if (hasFlag('--json')) {
|
|
156
|
+
console.log(JSON.stringify(job, null, 2));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(`\n${'═'.repeat(70)}`);
|
|
161
|
+
console.log(` ${job.title || 'Unknown'} at ${job.company || 'Unknown'}`);
|
|
162
|
+
console.log(`${'═'.repeat(70)}`);
|
|
163
|
+
console.log(` ID: ${job.id}`);
|
|
164
|
+
console.log(` Location: ${formatLocation(job.location, job.remote)}`);
|
|
165
|
+
console.log(` Salary: ${formatSalary(job.salary, job.salary_usd)}`);
|
|
166
|
+
console.log(` Type: ${job.type || '—'}`);
|
|
167
|
+
console.log(` Experience: ${job.experience || '—'}`);
|
|
168
|
+
console.log(` Posted: ${job.posted_at || '—'}`);
|
|
169
|
+
console.log(
|
|
170
|
+
` State: ${
|
|
171
|
+
job.state ? `${stateIcon(job.state)} ${job.state}` : 'none'
|
|
172
|
+
}`
|
|
173
|
+
);
|
|
174
|
+
console.log(` HN URL: ${job.url || '—'}`);
|
|
175
|
+
|
|
176
|
+
if (job.description) {
|
|
177
|
+
console.log(`\n Description:\n ${job.description}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (job.skills?.length) {
|
|
181
|
+
console.log(`\n Skills: ${job.skills.map((s) => s.name).join(', ')}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (job.responsibilities?.length) {
|
|
185
|
+
console.log(`\n Responsibilities:`);
|
|
186
|
+
job.responsibilities.forEach((r) => console.log(` • ${r}`));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (job.qualifications?.length) {
|
|
190
|
+
console.log(`\n Qualifications:`);
|
|
191
|
+
job.qualifications.forEach((q) => console.log(` • ${q}`));
|
|
192
|
+
}
|
|
193
|
+
console.log('');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function cmdMark() {
|
|
197
|
+
const id = process.argv[3];
|
|
198
|
+
const state = process.argv[4];
|
|
199
|
+
const feedback = getArg('--feedback');
|
|
200
|
+
|
|
201
|
+
if (!id || !state) {
|
|
202
|
+
console.error(
|
|
203
|
+
'Usage: jsonresume-jobs mark <job_id> <interested|not_interested|applied|dismissed|maybe> [--feedback "reason"]'
|
|
204
|
+
);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const body = { state };
|
|
209
|
+
if (feedback) body.feedback = feedback;
|
|
210
|
+
|
|
211
|
+
const result = await api(`/jobs/${id}`, {
|
|
212
|
+
method: 'PUT',
|
|
213
|
+
body: JSON.stringify(body),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const desc = result.job_title
|
|
217
|
+
? `${result.job_title} at ${result.job_company}`
|
|
218
|
+
: `#${result.id}`;
|
|
219
|
+
console.log(`${stateIcon(result.state)} Marked ${desc} as ${result.state}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function cmdMe() {
|
|
223
|
+
const data = await api('/me');
|
|
224
|
+
|
|
225
|
+
if (hasFlag('--json')) {
|
|
226
|
+
console.log(JSON.stringify(data, null, 2));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const r = data.resume;
|
|
231
|
+
console.log(`\n ${r.basics?.name} (${data.username})`);
|
|
232
|
+
console.log(` ${r.basics?.label || ''}`);
|
|
233
|
+
console.log(
|
|
234
|
+
` ${r.basics?.location?.city || ''}, ${
|
|
235
|
+
r.basics?.location?.countryCode || ''
|
|
236
|
+
}`
|
|
237
|
+
);
|
|
238
|
+
console.log(`\n Skills: ${(r.skills || []).map((s) => s.name).join(', ')}`);
|
|
239
|
+
console.log(` Work: ${(r.work || []).length} entries`);
|
|
240
|
+
console.log(` Projects: ${(r.projects || []).length} entries`);
|
|
241
|
+
console.log('');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function cmdUpdate() {
|
|
245
|
+
const filePath = process.argv[3];
|
|
246
|
+
if (!filePath) {
|
|
247
|
+
console.error('Usage: jsonresume-jobs update <path-to-resume.json>');
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const fs = await import('node:fs');
|
|
252
|
+
const path = await import('node:path');
|
|
253
|
+
const resolved = path.resolve(filePath);
|
|
254
|
+
|
|
255
|
+
let resume;
|
|
256
|
+
try {
|
|
257
|
+
const raw = fs.readFileSync(resolved, 'utf-8');
|
|
258
|
+
resume = JSON.parse(raw);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error(`Error reading ${resolved}: ${err.message}`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!resume.basics) {
|
|
265
|
+
console.error('Invalid resume — must have a "basics" section.');
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const result = await api('/resume', {
|
|
270
|
+
method: 'PUT',
|
|
271
|
+
body: JSON.stringify(resume),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
console.log(`Resume updated for ${result.username}.`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function cmdLogout() {
|
|
278
|
+
const { loadConfig, saveConfig } = await import('../src/auth.js');
|
|
279
|
+
const config = loadConfig();
|
|
280
|
+
const username = config.username || 'unknown';
|
|
281
|
+
saveConfig({});
|
|
282
|
+
console.log(`Logged out (removed saved key for ${username}).`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function cmdHelp() {
|
|
286
|
+
console.log(`
|
|
287
|
+
jsonresume-jobs v${VERSION} — Search HN "Who is Hiring" jobs matched to your JSON Resume
|
|
288
|
+
|
|
289
|
+
QUICK START
|
|
290
|
+
npx @jsonresume/jobs Launch the interactive TUI (logs in automatically)
|
|
291
|
+
|
|
292
|
+
COMMANDS
|
|
293
|
+
(default) Interactive TUI with AI features
|
|
294
|
+
search Find matching jobs (table output)
|
|
295
|
+
detail <id> Show full details for a job
|
|
296
|
+
mark <id> <state> Mark a job's state
|
|
297
|
+
me Show your resume summary
|
|
298
|
+
update <file> Update your resume on the registry
|
|
299
|
+
logout Remove saved API key
|
|
300
|
+
help Show this help message
|
|
301
|
+
|
|
302
|
+
SEARCH OPTIONS
|
|
303
|
+
--top N Number of results (default: 20, max: 100)
|
|
304
|
+
--days N How far back to look (default: 30)
|
|
305
|
+
--remote Remote jobs only
|
|
306
|
+
--min-salary N Minimum salary in thousands (e.g. 150)
|
|
307
|
+
--search TERM Keyword filter (searches title, company, skills)
|
|
308
|
+
--interested Show only jobs you marked interested
|
|
309
|
+
--applied Show only jobs you marked applied
|
|
310
|
+
|
|
311
|
+
MARK STATES
|
|
312
|
+
interested You want this job
|
|
313
|
+
not_interested Not for you
|
|
314
|
+
applied You've applied
|
|
315
|
+
maybe Considering it
|
|
316
|
+
dismissed Hide from results
|
|
317
|
+
|
|
318
|
+
GLOBAL OPTIONS
|
|
319
|
+
--json Output raw JSON (for piping / Claude Code)
|
|
320
|
+
--base-url URL API base URL (default: https://registry.jsonresume.org)
|
|
321
|
+
--feedback "reason" Add a reason when marking (with mark command)
|
|
322
|
+
|
|
323
|
+
ENVIRONMENT
|
|
324
|
+
JSONRESUME_API_KEY Your API key (optional — auto-login if not set)
|
|
325
|
+
OPENAI_API_KEY Enable AI features (summaries, reranking)
|
|
326
|
+
JSONRESUME_BASE_URL API base URL override
|
|
327
|
+
|
|
328
|
+
EXAMPLES
|
|
329
|
+
npx @jsonresume/jobs # TUI
|
|
330
|
+
npx @jsonresume/jobs search --remote --min-salary 150 # CLI search
|
|
331
|
+
npx @jsonresume/jobs detail 181420 # Job details
|
|
332
|
+
npx @jsonresume/jobs mark 181420 interested --feedback "great" # Mark job
|
|
333
|
+
`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── Main ───────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
async function main() {
|
|
339
|
+
const cmd = process.argv[2] || '';
|
|
340
|
+
|
|
341
|
+
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
342
|
+
return cmdHelp();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (cmd === '--version' || cmd === '-v') {
|
|
346
|
+
console.log(VERSION);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (cmd === 'logout') {
|
|
351
|
+
return cmdLogout();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
switch (cmd) {
|
|
355
|
+
case 'search':
|
|
356
|
+
return cmdSearch();
|
|
357
|
+
case 'detail':
|
|
358
|
+
return cmdDetail();
|
|
359
|
+
case 'mark':
|
|
360
|
+
return cmdMark();
|
|
361
|
+
case 'me':
|
|
362
|
+
return cmdMe();
|
|
363
|
+
case 'update':
|
|
364
|
+
return cmdUpdate();
|
|
365
|
+
case 'browse':
|
|
366
|
+
case 'tui':
|
|
367
|
+
case '':
|
|
368
|
+
default: {
|
|
369
|
+
// Default: launch TUI
|
|
370
|
+
const apiKey = await getApiKey();
|
|
371
|
+
const { default: runTUI } = await import('../src/tui/App.js');
|
|
372
|
+
return runTUI({ baseUrl: BASE_URL, apiKey });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
main().catch((e) => {
|
|
378
|
+
console.error(`Fatal: ${e.message}`);
|
|
379
|
+
process.exit(1);
|
|
380
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jsonresume/jobs",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Search Hacker News jobs matched against your JSON Resume",
|
|
6
|
+
"bin": {
|
|
7
|
+
"jsonresume-jobs": "./bin/cli.js",
|
|
8
|
+
"jobs": "./bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"skills",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"jsonresume",
|
|
18
|
+
"jobs",
|
|
19
|
+
"hacker-news",
|
|
20
|
+
"job-search",
|
|
21
|
+
"cli",
|
|
22
|
+
"tui",
|
|
23
|
+
"ink"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/jsonresume/jsonresume.org",
|
|
28
|
+
"directory": "packages/job-search"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"author": "Thomas Davis <thomasalwyndavis@gmail.com>",
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@ai-sdk/anthropic": "^3.0.58",
|
|
40
|
+
"@ai-sdk/openai": "^3.0.41",
|
|
41
|
+
"ai": "^6.0.116",
|
|
42
|
+
"ink": "^6.8.0",
|
|
43
|
+
"ink-spinner": "^5.0.0",
|
|
44
|
+
"ink-text-input": "^6.0.0",
|
|
45
|
+
"react": "^19.2.4"
|
|
46
|
+
}
|
|
47
|
+
}
|