@jsonresume/jobs 0.8.0 → 0.10.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
@@ -6,18 +6,30 @@
6
6
 
7
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
8
 
9
+ ![demo](./demo.gif)
10
+
9
11
  ## Quick Start
10
12
 
13
+ **With a registry account:**
14
+
11
15
  ```bash
12
16
  npx @jsonresume/jobs
13
17
  ```
14
18
 
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).
19
+ The CLI walks you through login on first run — all you need is a resume hosted at [registry.jsonresume.org](https://registry.jsonresume.org).
20
+
21
+ **With a local resume file (no account needed):**
22
+
23
+ ```bash
24
+ npx @jsonresume/jobs --resume ./resume.json
25
+ ```
26
+
27
+ Or just drop a `resume.json` in your current directory and run `npx @jsonresume/jobs` — it's auto-detected.
16
28
 
17
29
  ## Prerequisites
18
30
 
19
31
  - **Node.js** >= 18
20
- - A JSON Resume on the [JSON Resume Registry](https://registry.jsonresume.org) (free, backed by your GitHub Gist)
32
+ - A [JSON Resume](https://jsonresume.org/schema/) — either hosted on the [registry](https://registry.jsonresume.org) or as a local `resume.json` file
21
33
  - *(Optional)* `OPENAI_API_KEY` — enables AI summaries and batch ranking in the TUI
22
34
 
23
35
  ## Installation
@@ -57,6 +69,27 @@ To clear saved credentials:
57
69
  npx @jsonresume/jobs logout
58
70
  ```
59
71
 
72
+ ## Local Mode (No Account)
73
+
74
+ You don't need a registry account to use the TUI. If you have a `resume.json` file following the [JSON Resume schema](https://jsonresume.org/schema/), you can use it directly:
75
+
76
+ ```bash
77
+ # Explicit path
78
+ npx @jsonresume/jobs --resume ./my-resume.json
79
+
80
+ # Auto-detect (looks for resume.json in current directory)
81
+ npx @jsonresume/jobs
82
+ ```
83
+
84
+ In local mode:
85
+
86
+ - Job matching works the same way (your resume is sent to the server for embedding and vector search)
87
+ - **Marks are saved locally** to `~/.jsonresume/local-marks.json` instead of the server
88
+ - Filters and export work identically
89
+ - Custom search profiles are not available (they require a registry account for server-side storage)
90
+
91
+ This is useful if you want to try the tool without setting up a registry account, or if you prefer to keep your resume as a local file.
92
+
60
93
  ## Interactive TUI
61
94
 
62
95
  The default command launches a full terminal interface for browsing and managing jobs.
@@ -65,20 +98,31 @@ The default command launches a full terminal interface for browsing and managing
65
98
  npx @jsonresume/jobs
66
99
  ```
67
100
 
101
+ ### Layout
102
+
103
+ The TUI has three main regions:
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
106
+ - **Content area** — job list in list view, or a split-pane layout (40% compact list + 60% job detail) in detail view
107
+ - **Status bar** — context-sensitive keyboard hints, loading/reranking indicators, and toast notifications
108
+
68
109
  ### Features
69
110
 
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
111
+ - **Split-pane detail view** — press `Enter` to see a compact job list on the left and full details on the right; navigate jobs with `j`/`k` without leaving the detail panel
112
+ - **Tab-based views** — All / Interested / Applied / Maybe / Passed — always visible with live counts, cycle with `Tab`/`Shift+Tab`
113
+ - **Persistent filters** — remote, salary, keyword, date range — saved to disk per search profile and restored when you switch profiles
114
+ - **Custom search profiles** — targeted searches like "remote React jobs in climate tech" with AI-powered reranking via HyDE embeddings
74
115
  - **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
116
+ - **Batch operations** — select multiple jobs with `v`, then bulk-mark them all at once with `i`/`x`/`m`/`p`
117
+ - **Inline search** — press `n` to quickly filter visible jobs by keyword; press `Esc` to clear
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
+ - **Toast notifications** — instant feedback on every action (marking, exporting, refreshing)
120
+ - **Help modal** — press `?` for a full keyboard reference organized by section
121
+ - **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
+ - **Vim-style navigation** — `j`/`k` to move, `g`/`G` to jump to first/last, `Ctrl+U`/`Ctrl+D` to page up/down
123
+ - **Responsive columns** — job list columns (score, title, company, location, salary) adapt to terminal width on resize
124
+ - **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
82
126
 
83
127
  ### Keyboard Shortcuts
84
128
 
@@ -95,15 +139,15 @@ npx @jsonresume/jobs
95
139
  | `x` | Mark applied |
96
140
  | `m` | Mark maybe |
97
141
  | `p` | Mark passed |
98
- | `v` | Toggle batch selection |
142
+ | `v` | Toggle batch selection (selected jobs shown with `●`) |
99
143
  | `Tab` / `Shift+Tab` | Next / previous tab |
100
144
  | `n` | Inline keyword search |
101
145
  | `f` | Manage filters |
102
146
  | `/` | Search profiles |
103
- | `Space` | AI summary |
104
- | `S` | AI batch review |
147
+ | `Space` | AI summary for current job |
148
+ | `S` | AI batch review of visible jobs |
105
149
  | `e` | Export shortlist to markdown |
106
- | `R` | Force refresh |
150
+ | `R` | Force refresh (bypass cache) |
107
151
  | `?` | Help modal |
108
152
  | `q` | Quit |
109
153
 
@@ -111,22 +155,60 @@ npx @jsonresume/jobs
111
155
 
112
156
  | Key | Action |
113
157
  |-----|--------|
114
- | `j` / `k` | Navigate jobs (updates detail pane) |
115
- | `J` / `K` | Scroll detail content |
158
+ | `j` / `k` | Navigate between jobs (updates detail pane) |
159
+ | `J` / `K` | Scroll detail content up / down |
116
160
  | `o` | Open HN post in browser |
117
161
  | `i` / `x` / `m` / `p` | Mark job state |
118
162
  | `Space` | AI summary |
119
163
  | `Esc` / `q` | Back to full list |
120
164
 
121
- #### Filters & Search Profiles
165
+ #### Filters Panel
122
166
 
123
167
  | Key | Action |
124
168
  |-----|--------|
125
- | `a` | Add filter |
126
- | `d` | Delete filter / search profile |
127
- | `n` | New search profile |
128
- | `Enter` | Select / edit |
129
- | `Esc` | Close |
169
+ | `j` / `k` | Navigate filters |
170
+ | `Enter` | Edit filter value |
171
+ | `a` | Add filter (remote, salary, keyword, days) |
172
+ | `d` | Delete selected filter |
173
+ | `Esc` | Close panel |
174
+
175
+ #### Search Profiles Panel
176
+
177
+ | Key | Action |
178
+ |-----|--------|
179
+ | `j` / `k` | Navigate profiles |
180
+ | `Enter` | Switch to selected profile |
181
+ | `n` | Create new search profile |
182
+ | `d` | Delete selected profile |
183
+ | `Esc` | Close panel |
184
+
185
+ ### Job List Columns
186
+
187
+ In full-width list view, columns are:
188
+
189
+ | Column | Description |
190
+ |--------|-------------|
191
+ | Score | Cosine similarity (0.00–1.00) between your resume and the job |
192
+ | AI | LLM rerank score (1–10), shown only when a search profile triggers reranking |
193
+ | Title | Job title |
194
+ | Company | Company name |
195
+ | Location | City, country code, remote status |
196
+ | Salary | Parsed salary or `—` if not listed |
197
+ | Status | State icon: ⭐ interested, 📨 applied, ? maybe, ✗ passed |
198
+
199
+ In split-pane detail view, the left pane shows a compact list with just score, title, and status.
200
+
201
+ ### Tab Views
202
+
203
+ | Tab | Shows |
204
+ |-----|-------|
205
+ | All | All jobs from the current search (excludes passed/dismissed) |
206
+ | Interested | Jobs you marked with `i` |
207
+ | Applied | Jobs you marked with `x` |
208
+ | Maybe | Jobs you marked with `m` |
209
+ | Passed | Jobs you marked with `p` |
210
+
211
+ All tabs always appear in the header with their current count. Marking a job moves it between tabs instantly.
130
212
 
131
213
  ## CLI Commands
132
214
 
@@ -140,6 +222,7 @@ npx @jsonresume/jobs detail 181420 # Full job details
140
222
  npx @jsonresume/jobs mark 181420 interested # Mark a job
141
223
  npx @jsonresume/jobs me # Your resume summary
142
224
  npx @jsonresume/jobs update ./resume.json # Update your resume
225
+ npx @jsonresume/jobs logout # Remove saved API key
143
226
  npx @jsonresume/jobs help # All options
144
227
  ```
145
228
 
@@ -150,7 +233,7 @@ npx @jsonresume/jobs help # All options
150
233
  | *(default)* | Launch interactive TUI |
151
234
  | `search` | Find matching jobs (table output) |
152
235
  | `detail <id>` | Show full details for a job |
153
- | `mark <id> <state>` | Set job state: `interested`, `not_interested`, `applied`, `maybe`, `dismissed` |
236
+ | `mark <id> <state>` | Set job state (see states below) |
154
237
  | `me` | Show your resume summary |
155
238
  | `update <file>` | Upload a new version of your resume |
156
239
  | `logout` | Remove saved API key |
@@ -176,8 +259,8 @@ npx @jsonresume/jobs help # All options
176
259
  | `interested` | ⭐ | You want this job |
177
260
  | `applied` | 📨 | You've applied |
178
261
  | `maybe` | ? | Considering it |
179
- | `not_interested` | ✗ | Not for you |
180
- | `dismissed` | 👁 | Hide from results |
262
+ | `not_interested` | ✗ | Not for you (hidden from future searches) |
263
+ | `dismissed` | 👁 | Hide from results (hidden from future searches) |
181
264
 
182
265
  ## How Ranking Works
183
266
 
@@ -191,7 +274,7 @@ Job postings from HN's monthly "Who is Hiring?" threads are parsed by GPT into s
191
274
 
192
275
  ### Stage 2: Vector Similarity Search
193
276
 
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.
277
+ 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 or dismissed are excluded server-side (with 5x over-fetch to compensate) so you always get fresh results.
195
278
 
196
279
  ### Stage 3: Custom Search Profiles
197
280
 
@@ -207,11 +290,11 @@ For custom searches, the top 30 vector results are re-scored by `gpt-4.1-mini`.
207
290
 
208
291
  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
292
 
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.
293
+ In the TUI, this runs as a two-pass load: jobs appear instantly from vector search, then reshuffle when reranking finishes in the background. The status bar shows "reranking..." while this is in progress.
211
294
 
212
295
  ### Stage 5: Client-side Filtering
213
296
 
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.
297
+ After server-side ranking, the TUI applies local filters (remote only, minimum salary, keyword, days). Filters are persisted per search profile to `~/.jsonresume/filters.json`, so switching profiles restores each one's filters automatically.
215
298
 
216
299
  ## Claude Code Skill
217
300
 
@@ -246,11 +329,16 @@ The skill interviews you about what you're looking for, runs multiple searches,
246
329
 
247
330
  ## Data Storage
248
331
 
332
+ All local data is stored under `~/.jsonresume/`:
333
+
249
334
  | Path | Contents |
250
335
  |------|----------|
251
- | `~/.jsonresume/config.json` | API key and username |
336
+ | `~/.jsonresume/config.json` | API key and username (registry mode) |
252
337
  | `~/.jsonresume/filters.json` | Saved filter presets per search profile |
253
- | `~/.jsonresume/cache/` | Cached job results (auto-expires) |
338
+ | `~/.jsonresume/cache/` | Cached job results (auto-expires after 24 hours) |
339
+ | `~/.jsonresume/local-marks.json` | Job marks in local mode |
340
+
341
+ The export command writes `job-hunt-YYYY-MM-DD.md` to your current working directory.
254
342
 
255
343
  ## Contributing
256
344
 
@@ -263,6 +351,26 @@ pnpm install
263
351
  node packages/job-search/bin/cli.js help
264
352
  ```
265
353
 
354
+ The TUI is built with [React Ink](https://github.com/vadimdemedes/ink) v6 using a `h()` helper (no JSX). Key source files:
355
+
356
+ | File | Purpose |
357
+ |------|---------|
358
+ | `bin/cli.js` | Entry point, CLI command routing |
359
+ | `src/auth.js` | Interactive login flow, API key management |
360
+ | `src/tui/App.js` | Main TUI orchestrator, view state, layout |
361
+ | `src/tui/Header.js` | Title bar, tab bar, filter pills |
362
+ | `src/tui/JobList.js` | Responsive job table with flexbox columns |
363
+ | `src/tui/JobDetail.js` | Full job detail view (standalone and split-pane) |
364
+ | `src/tui/StatusBar.js` | Key hints, loading state, toasts |
365
+ | `src/tui/useJobs.js` | Job fetching, caching, tab filtering |
366
+ | `src/tui/useAI.js` | AI summary and batch review integration |
367
+ | `src/tui/useSearches.js` | Search profile CRUD |
368
+ | `src/filters.js` | Persistent filter storage per search profile |
369
+ | `src/export.js` | Markdown export |
370
+ | `src/localApi.js` | API client for local mode (no registry account) |
371
+ | `src/localState.js` | Local mark storage for local mode |
372
+ | `src/cache.js` | Local result caching with TTL |
373
+
266
374
  See the repo root [CLAUDE.md](../../CLAUDE.md) for code standards and contribution guidelines.
267
375
 
268
376
  ## License
package/bin/cli.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Just run: npx @jsonresume/jobs
7
7
  */
8
8
 
9
- const VERSION = '0.8.0';
9
+ const VERSION = '0.10.0';
10
10
 
11
11
  const BASE_URL =
12
12
  getArg('--base-url') ||
@@ -25,6 +25,37 @@ function hasFlag(name) {
25
25
  return process.argv.includes(name);
26
26
  }
27
27
 
28
+ // ── Local resume detection ────────────────────────────────
29
+
30
+ async function findLocalResume() {
31
+ const explicitPath = getArg('--resume');
32
+ if (explicitPath) {
33
+ const fs = await import('node:fs');
34
+ const path = await import('node:path');
35
+ const resolved = path.resolve(explicitPath);
36
+ try {
37
+ return JSON.parse(fs.readFileSync(resolved, 'utf-8'));
38
+ } catch (err) {
39
+ console.error(`Error reading resume at ${resolved}: ${err.message}`);
40
+ process.exit(1);
41
+ }
42
+ }
43
+
44
+ // Auto-detect resume.json in CWD
45
+ const fs = await import('node:fs');
46
+ const path = await import('node:path');
47
+ const candidates = ['resume.json', 'Resume.json'];
48
+ for (const name of candidates) {
49
+ const filePath = path.resolve(name);
50
+ try {
51
+ const raw = fs.readFileSync(filePath, 'utf-8');
52
+ const resume = JSON.parse(raw);
53
+ if (resume.basics) return resume;
54
+ } catch {}
55
+ }
56
+ return null;
57
+ }
58
+
28
59
  // ── API client ─────────────────────────────────────────────
29
60
 
30
61
  let _apiKey;
@@ -87,10 +118,12 @@ function stateIcon(state) {
87
118
 
88
119
  async function cmdSearch() {
89
120
  const params = new URLSearchParams();
90
- params.set('top', getArg('--top') || '20');
91
- params.set('days', getArg('--days') || '30');
121
+ const topArg = parseInt(getArg('--top')) || 20;
122
+ params.set('top', String(Math.min(Math.max(1, topArg), 100)));
123
+ params.set('days', String(parseInt(getArg('--days')) || 30));
92
124
  if (hasFlag('--remote')) params.set('remote', 'true');
93
- if (getArg('--min-salary')) params.set('min_salary', getArg('--min-salary'));
125
+ const minSalary = parseInt(getArg('--min-salary'));
126
+ if (minSalary > 0) params.set('min_salary', String(minSalary));
94
127
  if (getArg('--search')) params.set('search', getArg('--search'));
95
128
 
96
129
  const { jobs } = await api(`/jobs?${params}`);
@@ -288,6 +321,12 @@ jsonresume-jobs v${VERSION} — Search HN "Who is Hiring" jobs matched to your J
288
321
 
289
322
  QUICK START
290
323
  npx @jsonresume/jobs Launch the interactive TUI (logs in automatically)
324
+ npx @jsonresume/jobs --resume resume.json Use a local resume (no account needed)
325
+
326
+ LOCAL MODE
327
+ If a resume.json file exists in the current directory, or you pass --resume,
328
+ the TUI launches without requiring a registry account. Job matches are fetched
329
+ from the server using your resume, and marks are saved locally.
291
330
 
292
331
  COMMANDS
293
332
  (default) Interactive TUI with AI features
@@ -316,6 +355,7 @@ MARK STATES
316
355
  dismissed Hide from results
317
356
 
318
357
  GLOBAL OPTIONS
358
+ --resume <path> Use a local resume file (skips registry auth)
319
359
  --json Output raw JSON (for piping / Claude Code)
320
360
  --base-url URL API base URL (default: https://registry.jsonresume.org)
321
361
  --feedback "reason" Add a reason when marking (with mark command)
@@ -326,7 +366,8 @@ ENVIRONMENT
326
366
  JSONRESUME_BASE_URL API base URL override
327
367
 
328
368
  EXAMPLES
329
- npx @jsonresume/jobs # TUI
369
+ npx @jsonresume/jobs # TUI (registry)
370
+ npx @jsonresume/jobs --resume ./resume.json # TUI (local)
330
371
  npx @jsonresume/jobs search --remote --min-salary 150 # CLI search
331
372
  npx @jsonresume/jobs detail 181420 # Job details
332
373
  npx @jsonresume/jobs mark 181420 interested --feedback "great" # Mark job
@@ -366,7 +407,23 @@ async function main() {
366
407
  case 'tui':
367
408
  case '':
368
409
  default: {
369
- // Default: launch TUI
410
+ // Check for local resume first
411
+ const localResume = await findLocalResume();
412
+ if (localResume) {
413
+ console.error(
414
+ `\n Using local resume: ${localResume.basics?.name || 'Unknown'}`
415
+ );
416
+ console.error(' Marks will be saved locally.\n');
417
+ const { createLocalApiClient } = await import('../src/localApi.js');
418
+ const localApi = createLocalApiClient({
419
+ baseUrl: BASE_URL,
420
+ resume: localResume,
421
+ });
422
+ const { default: runTUI } = await import('../src/tui/App.js');
423
+ return runTUI({ baseUrl: BASE_URL, apiClient: localApi });
424
+ }
425
+
426
+ // Registry mode
370
427
  const apiKey = await getApiKey();
371
428
  const { default: runTUI } = await import('../src/tui/App.js');
372
429
  return runTUI({ baseUrl: BASE_URL, apiKey });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonresume/jobs",
3
- "version": "0.8.0",
3
+ "version": "0.10.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
@@ -40,7 +40,7 @@ export function createApiClient({ baseUrl, apiKey }) {
40
40
  markJob: (id, state, feedback) =>
41
41
  request(`/jobs/${id}`, {
42
42
  method: 'PUT',
43
- body: JSON.stringify({ state, feedback: feedback || state }),
43
+ body: JSON.stringify({ state, feedback: feedback || undefined }),
44
44
  }),
45
45
  fetchMe: () => request('/me'),
46
46
 
package/src/formatters.js CHANGED
@@ -39,26 +39,6 @@ export function truncate(str, len) {
39
39
  return str.length > len ? str.slice(0, len - 1) + '…' : str;
40
40
  }
41
41
 
42
- /**
43
- * Render a score as a sparkline bar: ████░░░░ 0.72
44
- * Uses Unicode block characters for sub-character precision.
45
- */
46
- const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
47
-
48
- export function scoreBar(score, width = 6) {
49
- if (typeof score !== 'number' || isNaN(score)) return '░'.repeat(width);
50
- const clamped = Math.max(0, Math.min(1, score));
51
- const filled = clamped * width;
52
- const full = Math.floor(filled);
53
- const frac = Math.round((filled - full) * 8);
54
- const empty = width - full - (frac > 0 ? 1 : 0);
55
- return (
56
- '█'.repeat(full) +
57
- (frac > 0 ? BLOCKS[frac] : '') +
58
- '░'.repeat(Math.max(0, empty))
59
- );
60
- }
61
-
62
42
  export function stateLabel(state) {
63
43
  const labels = {
64
44
  interested: 'Interested',
@@ -0,0 +1,72 @@
1
+ import { getMarks, setMark } from './localState.js';
2
+
3
+ const DEFAULT_BASE_URL = 'https://registry.jsonresume.org';
4
+
5
+ /**
6
+ * Creates an API client for local mode — uses a resume JSON object
7
+ * instead of registry auth. Marks are stored locally.
8
+ */
9
+ export function createLocalApiClient({ baseUrl, resume }) {
10
+ const base = baseUrl || DEFAULT_BASE_URL;
11
+
12
+ return {
13
+ fetchJobs: async (params = {}) => {
14
+ const body = {
15
+ resume,
16
+ top: params.top || 50,
17
+ days: params.days || 30,
18
+ };
19
+ if (params.remote) body.remote = true;
20
+ if (params.minSalary) body.min_salary = params.minSalary;
21
+ if (params.search) body.search = params.search;
22
+
23
+ const res = await fetch(`${base}/api/v1/jobs`, {
24
+ method: 'POST',
25
+ headers: { 'Content-Type': 'application/json' },
26
+ body: JSON.stringify(body),
27
+ });
28
+ const text = await res.text();
29
+ let data;
30
+ try {
31
+ data = JSON.parse(text);
32
+ } catch {
33
+ throw new Error(`Non-JSON response: ${res.status}`);
34
+ }
35
+ if (!res.ok) throw new Error(data.error || res.statusText);
36
+
37
+ // Overlay local marks onto results
38
+ const marks = getMarks();
39
+ const jobs = (data.jobs || []).map((j) => ({
40
+ ...j,
41
+ state: marks[String(j.id)] || j.state || null,
42
+ }));
43
+
44
+ return { jobs };
45
+ },
46
+
47
+ fetchJobDetail: async (id) => {
48
+ // In local mode we don't have a detail endpoint — return
49
+ // a stub so JobDetail falls back to the job data it already has.
50
+ return { id };
51
+ },
52
+
53
+ markJob: async (id, state, feedback) => {
54
+ setMark(id, state);
55
+ return { id, state, feedback };
56
+ },
57
+
58
+ fetchMe: async () => ({ resume, username: 'local' }),
59
+
60
+ // Search profiles not supported in local mode
61
+ listSearches: async () => ({ searches: [] }),
62
+ createSearch: async () => {
63
+ throw new Error('Search profiles require a registry account');
64
+ },
65
+ updateSearch: async () => {
66
+ throw new Error('Search profiles require a registry account');
67
+ },
68
+ deleteSearch: async () => {
69
+ throw new Error('Search profiles require a registry account');
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,37 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ const DIR = join(homedir(), '.jsonresume');
6
+ const FILE = join(DIR, 'local-marks.json');
7
+
8
+ function ensureDir() {
9
+ try {
10
+ mkdirSync(DIR, { recursive: true });
11
+ } catch {}
12
+ }
13
+
14
+ function load() {
15
+ try {
16
+ return JSON.parse(readFileSync(FILE, 'utf-8'));
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ function save(data) {
23
+ ensureDir();
24
+ writeFileSync(FILE, JSON.stringify(data, null, 2));
25
+ }
26
+
27
+ /** Get all marks as { jobId: state } */
28
+ export function getMarks() {
29
+ return load();
30
+ }
31
+
32
+ /** Set a mark for a job */
33
+ export function setMark(jobId, state) {
34
+ const marks = load();
35
+ marks[String(jobId)] = state;
36
+ save(marks);
37
+ }
package/src/tui/App.js CHANGED
@@ -36,17 +36,18 @@ const TAB_LABELS = {
36
36
  function InlineSearch({ query, onChange, onSubmit }) {
37
37
  return h(
38
38
  Box,
39
- { paddingX: 1 },
40
- h(Text, { color: 'yellow', bold: true }, 'Find: '),
41
- h(TextInput, { value: query, onChange, onSubmit })
39
+ { paddingX: 1, gap: 1 },
40
+ h(Text, { color: 'yellow', bold: true }, 'Find:'),
41
+ h(TextInput, { value: query, onChange, onSubmit }),
42
+ h(Text, { dimColor: true }, ' Enter to apply, Esc to clear')
42
43
  );
43
44
  }
44
45
 
45
- function App({ baseUrl, apiKey }) {
46
+ function App({ baseUrl, apiKey, apiClient }) {
46
47
  const { exit } = useApp();
47
48
  const api = useMemo(
48
- () => createApiClient({ baseUrl, apiKey }),
49
- [baseUrl, apiKey]
49
+ () => apiClient || createApiClient({ baseUrl, apiKey }),
50
+ [baseUrl, apiKey, apiClient]
50
51
  );
51
52
 
52
53
  // View: 'list' | 'detail' | 'filters' | 'searches' | 'ai' | 'help'
@@ -106,7 +107,18 @@ function App({ baseUrl, apiKey }) {
106
107
  const jobs = useMemo(() => {
107
108
  if (!appliedQuery) return rawJobs;
108
109
  const q = appliedQuery.toLowerCase();
109
- return rawJobs.filter((j) => JSON.stringify(j).toLowerCase().includes(q));
110
+ return rawJobs.filter((j) => {
111
+ const fields = [
112
+ j.title,
113
+ j.company,
114
+ j.description,
115
+ j.remote,
116
+ j.location?.city,
117
+ j.location?.countryCode,
118
+ ...(j.skills || []).map((s) => s.name || s),
119
+ ];
120
+ return fields.some((f) => f && String(f).toLowerCase().includes(q));
121
+ });
110
122
  }, [rawJobs, appliedQuery]);
111
123
 
112
124
  useEffect(() => {
@@ -215,7 +227,7 @@ function App({ baseUrl, apiKey }) {
215
227
  const handleExport = () => {
216
228
  try {
217
229
  const filename = exportShortlist(allJobs);
218
- showToast(`Exported to ${filename}`, 'export');
230
+ showToast(`Saved ./${filename}`, 'export');
219
231
  } catch (err) {
220
232
  showToast(`Export failed: ${err.message}`, 'error');
221
233
  }
@@ -389,6 +401,6 @@ function App({ baseUrl, apiKey }) {
389
401
  );
390
402
  }
391
403
 
392
- export default function runTUI({ baseUrl, apiKey }) {
393
- render(h(App, { baseUrl, apiKey }));
404
+ export default function runTUI({ baseUrl, apiKey, apiClient }) {
405
+ render(h(App, { baseUrl, apiKey, apiClient }));
394
406
  }
@@ -124,7 +124,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
124
124
  ...next[editIdx],
125
125
  value:
126
126
  filter.type === 'minSalary' || filter.type === 'days'
127
- ? parseInt(val) || val
127
+ ? parseInt(val) || 0
128
128
  : val,
129
129
  };
130
130
  onUpdate({ ...filterState, active: next });
@@ -132,11 +132,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
132
132
  },
133
133
  })
134
134
  ),
135
- h(
136
- Text,
137
- { dimColor: true, marginTop: 1 },
138
- 'Enter to save, Ctrl+C to cancel'
139
- )
135
+ h(Text, { dimColor: true, marginTop: 1 }, 'Enter to save, Esc to cancel')
140
136
  );
141
137
  }
142
138
 
@@ -165,7 +161,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
165
161
  if (val.trim()) {
166
162
  const value =
167
163
  ft.type === 'minSalary' || ft.type === 'days'
168
- ? parseInt(val) || val
164
+ ? parseInt(val) || 0
169
165
  : val;
170
166
  onUpdate({
171
167
  ...filterState,
package/src/tui/Header.js CHANGED
@@ -42,8 +42,6 @@ export default function Header({
42
42
  const tabElements = tabs.map((t) => {
43
43
  const active = t === tab;
44
44
  const count = counts[t] || 0;
45
- if (count === 0 && !active && t !== 'all') return null;
46
-
47
45
  const label = `${tabLabels[t]} ${count}`;
48
46
 
49
47
  if (active) {
@@ -64,7 +62,7 @@ export default function Header({
64
62
  );
65
63
  });
66
64
 
67
- const tabRow = h(Box, { paddingX: 1 }, ...tabElements.filter(Boolean));
65
+ const tabRow = h(Box, { paddingX: 1 }, ...tabElements);
68
66
 
69
67
  // ── Filter pills (only if active) ────────────────
70
68
  const tags = [];
@@ -7,10 +7,12 @@ const SECTIONS = [
7
7
  keys: [
8
8
  ['j / ↓', 'Move down'],
9
9
  ['k / ↑', 'Move up'],
10
- ['Enter', 'Open job details'],
10
+ ['g / G', 'Jump to first / last'],
11
+ ['Ctrl+U / D', 'Page up / page down'],
12
+ ['Enter', 'Open split-pane detail view'],
11
13
  ['Esc / q', 'Back / quit'],
12
- ['Tab', 'Next section tab'],
13
- ['Shift+Tab', 'Previous section tab'],
14
+ ['Tab', 'Next tab'],
15
+ ['Shift+Tab', 'Previous tab'],
14
16
  ],
15
17
  },
16
18
  {
@@ -20,28 +22,31 @@ const SECTIONS = [
20
22
  ['x', 'Mark applied'],
21
23
  ['m', 'Mark maybe'],
22
24
  ['p', 'Mark passed'],
25
+ ['v', 'Toggle batch selection'],
23
26
  ],
24
27
  },
25
28
  {
26
29
  title: 'Search & Filter',
27
30
  keys: [
31
+ ['n', 'Inline keyword search'],
28
32
  ['/', 'Search profiles'],
29
33
  ['f', 'Manage filters'],
30
- ['R', 'Force refresh'],
34
+ ['e', 'Export shortlist to markdown'],
35
+ ['R', 'Force refresh (bypass cache)'],
31
36
  ],
32
37
  },
33
38
  {
34
- title: 'AI Features',
39
+ title: 'Detail View',
35
40
  keys: [
36
- ['Space', 'AI summary of job'],
37
- ['S', 'AI batch review'],
41
+ ['J / K', 'Scroll detail content'],
42
+ ['o', 'Open HN post in browser'],
38
43
  ],
39
44
  },
40
45
  {
41
- title: 'Detail View',
46
+ title: 'AI Features (requires OPENAI_API_KEY)',
42
47
  keys: [
43
- ['o', 'Open HN post in browser'],
44
- ['e', 'Export shortlist to markdown'],
48
+ ['Space', 'AI summary of current job'],
49
+ ['S', 'AI batch review of visible jobs'],
45
50
  ],
46
51
  },
47
52
  ];
@@ -68,7 +73,7 @@ export default function HelpModal({ onClose }) {
68
73
  h(
69
74
  Box,
70
75
  { justifyContent: 'center', marginBottom: 1 },
71
- h(Text, { bold: true, color: 'cyan' }, 'Keyboard Shortcuts')
76
+ h(Text, { bold: true, color: 'cyan' }, 'Keyboard Shortcuts')
72
77
  ),
73
78
  ...SECTIONS.flatMap((section) => [
74
79
  h(
@@ -23,7 +23,8 @@ export default function JobDetail({
23
23
  api
24
24
  .fetchJobDetail(job.id)
25
25
  .then((d) => {
26
- setDetail(d);
26
+ // If the detail response only has an id (local mode), use the job data we already have
27
+ setDetail(d?.title ? d : null);
27
28
  setLoading(false);
28
29
  })
29
30
  .catch(() => setLoading(false));
@@ -73,7 +73,7 @@ export default function SearchManager({
73
73
  h(
74
74
  Text,
75
75
  { color: 'magenta' },
76
- ' Creating search profile... (AI is blending your resume with the prompt)'
76
+ ' Creating profile generating a custom embedding from your resume + search prompt'
77
77
  )
78
78
  )
79
79
  );
@@ -110,7 +110,7 @@ export default function SearchManager({
110
110
  h(
111
111
  Text,
112
112
  { dimColor: true, marginTop: 1 },
113
- 'Enter to continue, Ctrl+C to cancel'
113
+ 'Enter to continue, Esc to cancel'
114
114
  )
115
115
  );
116
116
  }
@@ -153,7 +153,7 @@ export default function SearchManager({
153
153
  h(
154
154
  Text,
155
155
  { dimColor: true, marginTop: 1 },
156
- 'Enter to create, Ctrl+C to cancel'
156
+ 'Enter to create, Esc to cancel'
157
157
  )
158
158
  );
159
159
  }
@@ -21,6 +21,10 @@ const KEYS = {
21
21
  ['space', 'AI'],
22
22
  ['esc', 'back'],
23
23
  ],
24
+ search: [
25
+ ['esc', 'clear search'],
26
+ ['enter', 'apply'],
27
+ ],
24
28
  filters: [
25
29
  ['j/k', 'nav'],
26
30
  ['enter', 'edit'],
@@ -67,14 +71,21 @@ export default function StatusBar({
67
71
  h(Text, { dimColor: true }, '─'.repeat(Math.max(10, cols - 2)))
68
72
  );
69
73
 
70
- const rightInfo = h(
71
- Box,
72
- { gap: 1 },
73
- loading ? h(Text, { color: 'yellow' }, 'loading…') : null,
74
- reranking ? h(Text, { color: 'magenta' }, 'reranking…') : null,
75
- h(Text, { dimColor: true }, `${jobCount}/${totalCount}`),
76
- aiEnabled ? null : h(Text, { dimColor: true }, 'no-AI')
74
+ const rightParts = [];
75
+ if (loading)
76
+ rightParts.push(h(Text, { key: 'l', color: 'yellow' }, 'loading…'));
77
+ if (reranking)
78
+ rightParts.push(h(Text, { key: 'r', color: 'magenta' }, 'reranking…'));
79
+ rightParts.push(
80
+ h(Text, { key: 'c', dimColor: true }, `${jobCount}/${totalCount}`)
77
81
  );
82
+ if (!aiEnabled) {
83
+ rightParts.push(
84
+ h(Text, { key: 'ai', dimColor: true }, 'set OPENAI_API_KEY for AI')
85
+ );
86
+ }
87
+
88
+ const rightInfo = h(Box, { gap: 1 }, ...rightParts);
78
89
 
79
90
  const content = toast
80
91
  ? h(Box, { paddingX: 1, justifyContent: 'space-between' }, toast, rightInfo)
package/src/tui/useAI.js CHANGED
@@ -11,7 +11,9 @@ export function useAI(resume) {
11
11
  const summarizeJob = useCallback(
12
12
  async (job) => {
13
13
  if (!hasKey) {
14
- setError('Set OPENAI_API_KEY to enable AI features');
14
+ setError(
15
+ 'AI requires OPENAI_API_KEY — run: export OPENAI_API_KEY=sk-...'
16
+ );
15
17
  return;
16
18
  }
17
19
  setLoading(true);
@@ -57,7 +59,9 @@ export function useAI(resume) {
57
59
  const batchReview = useCallback(
58
60
  async (jobs) => {
59
61
  if (!hasKey) {
60
- setError('Set OPENAI_API_KEY to enable AI features');
62
+ setError(
63
+ 'AI requires OPENAI_API_KEY — run: export OPENAI_API_KEY=sk-...'
64
+ );
61
65
  return;
62
66
  }
63
67
  setLoading(true);
@@ -1,68 +0,0 @@
1
- import { Box, Text } from 'ink';
2
- import { h } from './h.js';
3
- import { formatSalary, formatLocation, stateIcon } from '../formatters.js';
4
-
5
- export default function PreviewPane({ job }) {
6
- if (!job) return null;
7
-
8
- const cols = process.stdout.columns || 80;
9
- const loc = formatLocation(job.location, job.remote);
10
- const sal = formatSalary(job.salary, job.salary_usd);
11
- const icon = stateIcon(job.state);
12
-
13
- const skills = job.skills
14
- ? job.skills
15
- .slice(0, 8)
16
- .map((s) => s.name || s)
17
- .join(' · ')
18
- : null;
19
-
20
- const desc = job.description
21
- ? job.description.replace(/\n/g, ' ').slice(0, cols * 2)
22
- : null;
23
-
24
- return h(
25
- Box,
26
- {
27
- flexDirection: 'column',
28
- borderStyle: 'single',
29
- borderColor: 'gray',
30
- borderTop: true,
31
- borderBottom: false,
32
- borderLeft: false,
33
- borderRight: false,
34
- paddingX: 1,
35
- },
36
- // Title line
37
- h(
38
- Box,
39
- { gap: 1 },
40
- h(Text, { bold: true, color: 'white' }, job.title || '—'),
41
- h(Text, { dimColor: true }, 'at'),
42
- h(Text, { bold: true, color: 'cyan' }, job.company || '—'),
43
- job.state ? h(Text, null, ` ${icon}`) : null
44
- ),
45
- // Meta line
46
- h(
47
- Box,
48
- { gap: 2 },
49
- h(Text, { dimColor: true }, `📍 ${loc}`),
50
- h(Text, { dimColor: true }, `💰 ${sal}`),
51
- job.experience
52
- ? h(Text, { dimColor: true }, `📊 ${job.experience}`)
53
- : null,
54
- job.url ? h(Text, { color: 'blue', dimColor: true }, 'o:open') : null
55
- ),
56
- // Skills
57
- skills
58
- ? h(
59
- Box,
60
- null,
61
- h(Text, { color: 'yellow' }, 'Skills: '),
62
- h(Text, { dimColor: true }, skills)
63
- )
64
- : null,
65
- // Description snippet
66
- desc ? h(Text, { dimColor: true, wrap: 'truncate-end' }, desc) : null
67
- );
68
- }