@jsonresume/jobs 0.10.0 → 0.12.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 CHANGED
@@ -102,7 +102,7 @@ npx @jsonresume/jobs
102
102
 
103
103
  The TUI has three main regions:
104
104
 
105
- - **Header** — shows the app title, active search profile name, tab bar (All / Interested / Applied / Maybe / Passed) with live counts, and any active filter pills
105
+ - **Header** — shows the app title, active search profile name, tab bar (All / New / Reviewed / Interested / Applied / Maybe / Passed) with live counts, and any active filter pills
106
106
  - **Content area** — job list in list view, or a split-pane layout (40% compact list + 60% job detail) in detail view
107
107
  - **Status bar** — context-sensitive keyboard hints, loading/reranking indicators, and toast notifications
108
108
 
@@ -118,11 +118,12 @@ The TUI has three main regions:
118
118
  - **Export** — press `e` to export your shortlist, applied, and maybe lists to a `job-hunt-YYYY-MM-DD.md` markdown file in the current directory
119
119
  - **Toast notifications** — instant feedback on every action (marking, exporting, refreshing)
120
120
  - **Help modal** — press `?` for a full keyboard reference organized by section
121
+ - **Dossier research** — press `c` to spawn a Claude Code CLI session that researches the company, role, and generates a comprehensive dossier with fit assessment, talking points, interview prep, and compensation context. Results stream live and are cached server-side so you can revisit them anytime. Supports switching between multiple dossiers without restarting. Requires [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed
121
122
  - **AI summaries** — press `Space` for a per-job AI summary, or `S` for a batch review of all visible jobs (requires `OPENAI_API_KEY`)
122
123
  - **Vim-style navigation** — `j`/`k` to move, `g`/`G` to jump to first/last, `Ctrl+U`/`Ctrl+D` to page up/down
123
124
  - **Responsive columns** — job list columns (score, title, company, location, salary) adapt to terminal width on resize
124
125
  - **Smart filtering** — passed and dismissed jobs are excluded server-side with 5x over-fetch, so you always get a full set of fresh results
125
- - **Cached results** — job data is cached locally for 24 hours to minimize API calls; press `R` to force a fresh fetch
126
+ - **Cached results** — job data is cached locally for 2 hours to minimize API calls; press `R` to force a fresh fetch
126
127
 
127
128
  ### Keyboard Shortcuts
128
129
 
@@ -145,6 +146,7 @@ The TUI has three main regions:
145
146
  | `f` | Manage filters |
146
147
  | `/` | Search profiles |
147
148
  | `Space` | AI summary for current job |
149
+ | `c` | Research dossier via Claude Code CLI |
148
150
  | `S` | AI batch review of visible jobs |
149
151
  | `e` | Export shortlist to markdown |
150
152
  | `R` | Force refresh (bypass cache) |
@@ -160,6 +162,7 @@ The TUI has three main regions:
160
162
  | `o` | Open HN post in browser |
161
163
  | `i` / `x` / `m` / `p` | Mark job state |
162
164
  | `Space` | AI summary |
165
+ | `c` | Research dossier |
163
166
  | `Esc` / `q` | Back to full list |
164
167
 
165
168
  #### Filters Panel
@@ -203,6 +206,8 @@ In split-pane detail view, the left pane shows a compact list with just score, t
203
206
  | Tab | Shows |
204
207
  |-----|-------|
205
208
  | All | All jobs from the current search (excludes passed/dismissed) |
209
+ | New | Jobs with no state and no dossier — completely untouched |
210
+ | Reviewed | Jobs with a dossier but no decision yet |
206
211
  | Interested | Jobs you marked with `i` |
207
212
  | Applied | Jobs you marked with `x` |
208
213
  | Maybe | Jobs you marked with `m` |
@@ -262,6 +267,232 @@ npx @jsonresume/jobs help # All options
262
267
  | `not_interested` | ✗ | Not for you (hidden from future searches) |
263
268
  | `dismissed` | 👁 | Hide from results (hidden from future searches) |
264
269
 
270
+ ## Architecture
271
+
272
+ ![architecture](./architecture.svg)
273
+
274
+ ## API Reference
275
+
276
+ All endpoints live at `https://registry.jsonresume.org/api/v1`. Authenticated endpoints require a `Bearer` token in the `Authorization` header.
277
+
278
+ ### Authentication
279
+
280
+ ```bash
281
+ # Generate an API key (no auth required)
282
+ curl -s -X POST https://registry.jsonresume.org/api/v1/keys \
283
+ -H 'Content-Type: application/json' \
284
+ -d '{"username":"YOUR_GITHUB_USERNAME"}'
285
+ # → { "key": "jr_username_xxxxx", "username": "username" }
286
+
287
+ # Use in all subsequent requests
288
+ export JSONRESUME_API_KEY="jr_username_xxxxx"
289
+ AUTH="Authorization: Bearer $JSONRESUME_API_KEY"
290
+ ```
291
+
292
+ ### GET /api/v1/me
293
+
294
+ Returns your resume and profile.
295
+
296
+ ```bash
297
+ curl -s -H "$AUTH" https://registry.jsonresume.org/api/v1/me
298
+ # → { "username": "...", "resume": { ... } }
299
+ ```
300
+
301
+ ### GET /api/v1/jobs
302
+
303
+ Get jobs matched to your resume via vector similarity.
304
+
305
+ | Param | Type | Default | Description |
306
+ |-------|------|---------|-------------|
307
+ | `top` | int | 20 | Number of results (max 100) |
308
+ | `days` | int | 30 | How far back to search |
309
+ | `remote` | bool | false | Remote jobs only |
310
+ | `min_salary` | int | 0 | Minimum salary in thousands (e.g. `150` = $150k) |
311
+ | `search` | string | | Keyword filter across all fields |
312
+ | `search_id` | uuid | | Use a saved search profile's embedding |
313
+ | `rerank` | bool | auto | LLM reranking (defaults to true when `search_id` is set) |
314
+
315
+ ```bash
316
+ # Basic search
317
+ curl -s -H "$AUTH" "https://registry.jsonresume.org/api/v1/jobs?top=50&days=30"
318
+
319
+ # Remote jobs, $150k+, keyword filter
320
+ curl -s -H "$AUTH" "https://registry.jsonresume.org/api/v1/jobs?top=50&remote=true&min_salary=150&search=react"
321
+
322
+ # Using a search profile with reranking
323
+ curl -s -H "$AUTH" "https://registry.jsonresume.org/api/v1/jobs?top=50&search_id=UUID&rerank=true"
324
+ ```
325
+
326
+ **Response:**
327
+ ```json
328
+ {
329
+ "jobs": [
330
+ {
331
+ "id": 237247,
332
+ "uuid": "...",
333
+ "title": "Full-Stack Engineer",
334
+ "company": "Acme Corp",
335
+ "location": { "city": "San Francisco", "countryCode": "US" },
336
+ "remote": "Full",
337
+ "salary": "$180k - $220k",
338
+ "salary_usd": 180000,
339
+ "experience": "Senior",
340
+ "type": "Full-time",
341
+ "description": "...",
342
+ "skills": [{ "name": "React" }, { "name": "Node.js" }],
343
+ "url": "https://news.ycombinator.com/item?id=...",
344
+ "posted_at": "2026-03-01T...",
345
+ "similarity": 0.654,
346
+ "rerank_score": 8,
347
+ "combined_score": 0.74,
348
+ "state": "interested",
349
+ "has_dossier": true
350
+ }
351
+ ],
352
+ "total": 50
353
+ }
354
+ ```
355
+
356
+ ### POST /api/v1/jobs
357
+
358
+ Match jobs against a provided resume — **no auth required**. Useful for local mode or one-off queries.
359
+
360
+ ```bash
361
+ curl -s -X POST https://registry.jsonresume.org/api/v1/jobs \
362
+ -H 'Content-Type: application/json' \
363
+ -d '{
364
+ "resume": { "basics": { "name": "...", "label": "..." }, "skills": [...] },
365
+ "top": 20,
366
+ "days": 30,
367
+ "remote": true
368
+ }'
369
+ ```
370
+
371
+ ### GET /api/v1/jobs/:id
372
+
373
+ Full details for a single job including raw posting content.
374
+
375
+ ```bash
376
+ curl -s -H "$AUTH" https://registry.jsonresume.org/api/v1/jobs/237247
377
+ ```
378
+
379
+ ### PUT /api/v1/jobs/:id
380
+
381
+ Mark a job's state.
382
+
383
+ | Body Param | Type | Description |
384
+ |------------|------|-------------|
385
+ | `state` | string | `interested`, `applied`, `maybe`, `not_interested`, or `dismissed` |
386
+ | `feedback` | string | Optional reason/notes |
387
+
388
+ ```bash
389
+ # Mark as interested
390
+ curl -s -X PUT -H "$AUTH" -H 'Content-Type: application/json' \
391
+ https://registry.jsonresume.org/api/v1/jobs/237247 \
392
+ -d '{"state":"interested","feedback":"great remote role, strong tech stack"}'
393
+
394
+ # Mark as applied
395
+ curl -s -X PUT -H "$AUTH" -H 'Content-Type: application/json' \
396
+ https://registry.jsonresume.org/api/v1/jobs/237247 \
397
+ -d '{"state":"applied","feedback":"applied via email 2026-03-13"}'
398
+
399
+ # Pass on a job
400
+ curl -s -X PUT -H "$AUTH" -H 'Content-Type: application/json' \
401
+ https://registry.jsonresume.org/api/v1/jobs/237247 \
402
+ -d '{"state":"not_interested","feedback":"salary too low"}'
403
+ ```
404
+
405
+ ### GET /api/v1/jobs/:id/dossier
406
+
407
+ Fetch saved research dossier for a job.
408
+
409
+ ```bash
410
+ curl -s -H "$AUTH" https://registry.jsonresume.org/api/v1/jobs/237247/dossier
411
+ # → { "content": "# Dossier\n\n## Compensation...", "created_at": "..." }
412
+ # → { "content": null } if no dossier exists
413
+ ```
414
+
415
+ ### PUT /api/v1/jobs/:id/dossier
416
+
417
+ Save or update a research dossier.
418
+
419
+ ```bash
420
+ curl -s -X PUT -H "$AUTH" -H 'Content-Type: application/json' \
421
+ https://registry.jsonresume.org/api/v1/jobs/237247/dossier \
422
+ -d '{"content":"# Company Research\n\n..."}'
423
+ ```
424
+
425
+ ### PUT /api/v1/resume
426
+
427
+ Update your resume on the registry.
428
+
429
+ ```bash
430
+ curl -s -X PUT -H "$AUTH" -H 'Content-Type: application/json' \
431
+ https://registry.jsonresume.org/api/v1/resume \
432
+ -d @resume.json
433
+ # → { "username": "...", "message": "Resume updated" }
434
+ ```
435
+
436
+ ### GET /api/v1/searches
437
+
438
+ List your saved search profiles.
439
+
440
+ ```bash
441
+ curl -s -H "$AUTH" https://registry.jsonresume.org/api/v1/searches
442
+ # → { "searches": [{ "id": "uuid", "name": "Remote React", "prompt": "...", "filters": [...] }] }
443
+ ```
444
+
445
+ ### POST /api/v1/searches
446
+
447
+ Create a new search profile. The server generates a HyDE embedding from your prompt + resume.
448
+
449
+ ```bash
450
+ curl -s -X POST -H "$AUTH" -H 'Content-Type: application/json' \
451
+ https://registry.jsonresume.org/api/v1/searches \
452
+ -d '{"name":"Remote React","prompt":"remote React roles at climate tech startups"}'
453
+ # → { "search": { "id": "uuid", "name": "Remote React", "prompt": "..." } }
454
+ ```
455
+
456
+ ### PUT /api/v1/searches/:id
457
+
458
+ Update a search profile's name or filters.
459
+
460
+ ```bash
461
+ curl -s -X PUT -H "$AUTH" -H 'Content-Type: application/json' \
462
+ https://registry.jsonresume.org/api/v1/searches/UUID \
463
+ -d '{"name":"Updated Name","filters":[{"type":"remote"},{"type":"minSalary","value":"150"}]}'
464
+ ```
465
+
466
+ ### DELETE /api/v1/searches/:id
467
+
468
+ Delete a search profile.
469
+
470
+ ```bash
471
+ curl -s -X DELETE -H "$AUTH" https://registry.jsonresume.org/api/v1/searches/UUID
472
+ # → { "ok": true }
473
+ ```
474
+
475
+ ### Example: Automated Job Application Workflow
476
+
477
+ Use the API to build your own automation — e.g., have Claude in Chrome review your "maybe" jobs and apply:
478
+
479
+ ```bash
480
+ # 1. Get all jobs, filter to "maybe" state
481
+ JOBS=$(curl -s -H "$AUTH" "https://registry.jsonresume.org/api/v1/jobs?top=100" \
482
+ | jq '[.jobs[] | select(.state == "maybe")]')
483
+
484
+ # 2. For each, get the full detail + dossier
485
+ for ID in $(echo $JOBS | jq -r '.[].id'); do
486
+ DETAIL=$(curl -s -H "$AUTH" "https://registry.jsonresume.org/api/v1/jobs/$ID")
487
+ DOSSIER=$(curl -s -H "$AUTH" "https://registry.jsonresume.org/api/v1/jobs/$ID/dossier")
488
+ echo "$DETAIL" | jq '{title: .title, company: .company, url: .url}'
489
+ # ... use the detail + dossier to apply, then mark as applied
490
+ curl -s -X PUT -H "$AUTH" -H 'Content-Type: application/json' \
491
+ "https://registry.jsonresume.org/api/v1/jobs/$ID" \
492
+ -d '{"state":"applied","feedback":"auto-applied via script"}'
493
+ done
494
+ ```
495
+
265
496
  ## How Ranking Works
266
497
 
267
498
  The system uses a five-stage pipeline to match and rank jobs against your resume.
@@ -335,7 +566,7 @@ All local data is stored under `~/.jsonresume/`:
335
566
  |------|----------|
336
567
  | `~/.jsonresume/config.json` | API key and username (registry mode) |
337
568
  | `~/.jsonresume/filters.json` | Saved filter presets per search profile |
338
- | `~/.jsonresume/cache/` | Cached job results (auto-expires after 24 hours) |
569
+ | `~/.jsonresume/cache/` | Cached job results (auto-expires after 2 hours) |
339
570
  | `~/.jsonresume/local-marks.json` | Job marks in local mode |
340
571
 
341
572
  The export command writes `job-hunt-YYYY-MM-DD.md` to your current working directory.
@@ -363,7 +594,8 @@ The TUI is built with [React Ink](https://github.com/vadimdemedes/ink) v6 using
363
594
  | `src/tui/JobDetail.js` | Full job detail view (standalone and split-pane) |
364
595
  | `src/tui/StatusBar.js` | Key hints, loading state, toasts |
365
596
  | `src/tui/useJobs.js` | Job fetching, caching, tab filtering |
366
- | `src/tui/useAI.js` | AI summary and batch review integration |
597
+ | `src/tui/useAI.js` | AI summary, dossier research (Claude CLI), batch review |
598
+ | `src/tui/AIPanel.js` | Dossier/AI split-pane panel with scroll and export |
367
599
  | `src/tui/useSearches.js` | Search profile CRUD |
368
600
  | `src/filters.js` | Persistent filter storage per search profile |
369
601
  | `src/export.js` | Markdown export |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonresume/jobs",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "Search Hacker News jobs matched against your JSON Resume",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -57,5 +57,13 @@ export function createApiClient({ baseUrl, apiKey }) {
57
57
  body: JSON.stringify(updates),
58
58
  }),
59
59
  deleteSearch: (id) => request(`/searches/${id}`, { method: 'DELETE' }),
60
+
61
+ // Dossiers
62
+ fetchDossier: (id) => request(`/jobs/${id}/dossier`),
63
+ saveDossier: (id, content) =>
64
+ request(`/jobs/${id}/dossier`, {
65
+ method: 'PUT',
66
+ body: JSON.stringify({ content }),
67
+ }),
60
68
  };
61
69
  }
package/src/cache.js CHANGED
@@ -3,7 +3,7 @@ import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
 
5
5
  const CACHE_DIR = join(homedir(), '.jsonresume', 'cache');
6
- const CACHE_TTL = 24 * 60 * 60 * 1000; // 1 day
6
+ const CACHE_TTL = 2 * 60 * 60 * 1000; // 2 hours
7
7
 
8
8
  function ensureDir() {
9
9
  try {
@@ -14,6 +14,7 @@ function ensureDir() {
14
14
  function cacheKey(params) {
15
15
  const parts = [`jobs_${params.days || 30}_${params.top || 100}`];
16
16
  if (params.searchId) parts.push(`s_${params.searchId.slice(0, 8)}`);
17
+ if (params.mode) parts.push(params.mode);
17
18
  return parts.join('_') + '.json';
18
19
  }
19
20
 
package/src/localApi.js CHANGED
@@ -10,6 +10,7 @@ export function createLocalApiClient({ baseUrl, resume }) {
10
10
  const base = baseUrl || DEFAULT_BASE_URL;
11
11
 
12
12
  return {
13
+ mode: 'local',
13
14
  fetchJobs: async (params = {}) => {
14
15
  const body = {
15
16
  resume,
@@ -68,5 +69,9 @@ export function createLocalApiClient({ baseUrl, resume }) {
68
69
  deleteSearch: async () => {
69
70
  throw new Error('Search profiles require a registry account');
70
71
  },
72
+
73
+ // Dossiers — local mode stores in memory only
74
+ fetchDossier: async () => ({ content: null }),
75
+ saveDossier: async () => ({ saved: true }),
71
76
  };
72
77
  }
@@ -1,37 +1,140 @@
1
+ import { useState, useEffect, useRef } from 'react';
1
2
  import { Box, Text, useInput } from 'ink';
2
3
  import Spinner from 'ink-spinner';
3
4
  import { h } from './h.js';
4
5
 
5
- export default function AIPanel({ text, loading, error, onDismiss, isActive }) {
6
+ export default function AIPanel({
7
+ text,
8
+ loading,
9
+ error,
10
+ onDismiss,
11
+ onExport,
12
+ onRegenerate,
13
+ onMark,
14
+ job,
15
+ isActive,
16
+ mode,
17
+ }) {
18
+ const [scroll, setScroll] = useState(0);
19
+ const maxRows = Math.max((process.stdout.rows || 30) - 8, 10);
20
+ const exportMsg = useRef(null);
21
+
22
+ // Enable mouse reporting for scroll wheel
23
+ useEffect(() => {
24
+ if (!process.stdout.isTTY) return;
25
+ // Enable mouse wheel reporting (SGR mode)
26
+ process.stdout.write('\x1b[?1000h\x1b[?1006h');
27
+ const onData = (data) => {
28
+ const str = data.toString();
29
+ // SGR mouse: \x1b[<button;col;row(M|m)
30
+ const match = str.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
31
+ if (match) {
32
+ const btn = parseInt(match[1], 10);
33
+ if (btn === 64) setScroll((s) => Math.max(0, s - 3));
34
+ if (btn === 65) setScroll((s) => s + 3);
35
+ }
36
+ };
37
+ process.stdin.on('data', onData);
38
+ return () => {
39
+ process.stdin.removeListener('data', onData);
40
+ if (process.stdout.isTTY) {
41
+ process.stdout.write('\x1b[?1000l\x1b[?1006l');
42
+ }
43
+ };
44
+ }, []);
45
+
6
46
  useInput(
7
- (_input, key) => {
47
+ (input, key) => {
8
48
  if (key.escape) onDismiss();
49
+ if (input === 'e' && text && onExport) {
50
+ const filename = onExport();
51
+ if (filename) {
52
+ exportMsg.current = filename;
53
+ setTimeout(() => {
54
+ exportMsg.current = null;
55
+ }, 3000);
56
+ }
57
+ }
58
+ if (input === 'r' && job && onRegenerate && !loading) {
59
+ onRegenerate(job);
60
+ }
61
+ // Mark keys
62
+ if (job && onMark) {
63
+ if (input === 'i') onMark(job.id, 'interested');
64
+ if (input === 'x') onMark(job.id, 'applied');
65
+ if (input === 'm') onMark(job.id, 'maybe');
66
+ if (input === 'p') onMark(job.id, 'not_interested');
67
+ }
68
+ if (key.upArrow || input === 'k') setScroll((s) => Math.max(0, s - 1));
69
+ if (key.downArrow || input === 'j') setScroll((s) => s + 1);
70
+ if (key.pageUp || (key.ctrl && input === 'u'))
71
+ setScroll((s) => Math.max(0, s - 20));
72
+ if (key.pageDown || (key.ctrl && input === 'd')) setScroll((s) => s + 20);
73
+ if (input === 'g') setScroll(0);
74
+ if (input === 'G' && text) {
75
+ const lines = text.split('\n');
76
+ setScroll(Math.max(0, lines.length - maxRows));
77
+ }
9
78
  },
10
79
  { isActive }
11
80
  );
12
81
 
82
+ const isCover = mode === 'cover';
83
+ const title = isCover ? '📋 Job Dossier (via Claude Code)' : '🤖 AI Analysis';
84
+ const color = isCover ? 'green' : 'magenta';
85
+ const loadingMsg = isCover
86
+ ? ' Claude is researching… (streaming live)'
87
+ : ' Thinking...';
88
+
89
+ let displayText = text;
90
+ let totalLines = 0;
91
+ if (text) {
92
+ const lines = text.split('\n');
93
+ totalLines = lines.length;
94
+ if (loading) {
95
+ // While streaming, auto-scroll to bottom
96
+ displayText = lines.slice(Math.max(0, lines.length - maxRows)).join('\n');
97
+ } else {
98
+ // When done, allow manual scroll
99
+ const start = Math.min(scroll, Math.max(0, lines.length - maxRows));
100
+ displayText = lines.slice(start, start + maxRows).join('\n');
101
+ }
102
+ }
103
+
104
+ const scrollHint =
105
+ !loading && totalLines > maxRows
106
+ ? `j/k scroll · g/G top/bottom · ${scroll + 1}–${Math.min(
107
+ scroll + maxRows,
108
+ totalLines
109
+ )}/${totalLines}`
110
+ : '';
111
+
112
+ const exportHint = text ? ' · e export' : '';
113
+ const regenHint = isCover && !loading && job ? ' · r regenerate' : '';
114
+ const statusLine =
115
+ loading && text
116
+ ? 'Streaming… ESC to cancel'
117
+ : loading
118
+ ? 'ESC to cancel'
119
+ : scrollHint
120
+ ? `${scrollHint}${exportHint}${regenHint} · ESC to dismiss`
121
+ : `ESC to dismiss${exportHint}${regenHint}`;
122
+
13
123
  return h(
14
124
  Box,
15
125
  {
16
126
  flexDirection: 'column',
17
- borderStyle: 'double',
18
- borderColor: 'magenta',
19
- padding: 1,
20
- marginX: 2,
127
+ flexGrow: 1,
128
+ paddingX: 1,
21
129
  },
22
- h(Text, { bold: true, color: 'magenta' }, '🤖 AI Analysis'),
130
+ h(Text, { bold: true, color }, title),
23
131
  h(Text, null, ' '),
24
132
  loading
25
- ? h(
26
- Box,
27
- null,
28
- h(Spinner, { type: 'dots' }),
29
- h(Text, null, ' Thinking...')
30
- )
133
+ ? h(Box, null, h(Spinner, { type: 'dots' }), h(Text, null, loadingMsg))
31
134
  : null,
32
135
  error ? h(Text, { color: 'red' }, `Error: ${error}`) : null,
33
- text ? h(Text, { wrap: 'wrap' }, text) : null,
136
+ displayText ? h(Text, { wrap: 'wrap' }, displayText) : null,
34
137
  h(Text, null, ' '),
35
- h(Text, { dimColor: true }, 'Press ESC to dismiss')
138
+ h(Text, { dimColor: true }, statusLine)
36
139
  );
37
140
  }