@jsonresume/jobs 0.7.0 → 0.9.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
@@ -1,23 +1,35 @@
1
- # @jsonresume/job-search
1
+ # @jsonresume/jobs
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@jsonresume/job-search)](https://www.npmjs.com/package/@jsonresume/job-search)
4
- [![license](https://img.shields.io/npm/l/@jsonresume/job-search)](./LICENSE)
5
- [![node](https://img.shields.io/node/v/@jsonresume/job-search)](https://nodejs.org)
3
+ [![npm version](https://img.shields.io/npm/v/@jsonresume/jobs)](https://www.npmjs.com/package/@jsonresume/jobs)
4
+ [![license](https://img.shields.io/npm/l/@jsonresume/jobs)](./LICENSE)
5
+ [![node](https://img.shields.io/node/v/@jsonresume/jobs)](https://nodejs.org)
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
@@ -31,7 +43,7 @@ npx @jsonresume/jobs
31
43
  Or install globally:
32
44
 
33
45
  ```bash
34
- npm install -g @jsonresume/job-search
46
+ npm install -g @jsonresume/jobs
35
47
  jsonresume-jobs
36
48
  ```
37
49
 
@@ -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
 
@@ -221,7 +304,7 @@ This package includes a [Claude Code skill](https://docs.anthropic.com/en/docs/c
221
304
 
222
305
  ```bash
223
306
  mkdir -p ~/.claude/skills/jsonresume-hunt
224
- cp node_modules/@jsonresume/job-search/skills/jsonresume-hunt/SKILL.md \
307
+ cp node_modules/@jsonresume/jobs/skills/jsonresume-hunt/SKILL.md \
225
308
  ~/.claude/skills/jsonresume-hunt/SKILL.md
226
309
  ```
227
310
 
@@ -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.7.0';
9
+ const VERSION = '0.9.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;
@@ -288,6 +319,12 @@ jsonresume-jobs v${VERSION} — Search HN "Who is Hiring" jobs matched to your J
288
319
 
289
320
  QUICK START
290
321
  npx @jsonresume/jobs Launch the interactive TUI (logs in automatically)
322
+ npx @jsonresume/jobs --resume resume.json Use a local resume (no account needed)
323
+
324
+ LOCAL MODE
325
+ If a resume.json file exists in the current directory, or you pass --resume,
326
+ the TUI launches without requiring a registry account. Job matches are fetched
327
+ from the server using your resume, and marks are saved locally.
291
328
 
292
329
  COMMANDS
293
330
  (default) Interactive TUI with AI features
@@ -316,6 +353,7 @@ MARK STATES
316
353
  dismissed Hide from results
317
354
 
318
355
  GLOBAL OPTIONS
356
+ --resume <path> Use a local resume file (skips registry auth)
319
357
  --json Output raw JSON (for piping / Claude Code)
320
358
  --base-url URL API base URL (default: https://registry.jsonresume.org)
321
359
  --feedback "reason" Add a reason when marking (with mark command)
@@ -326,7 +364,8 @@ ENVIRONMENT
326
364
  JSONRESUME_BASE_URL API base URL override
327
365
 
328
366
  EXAMPLES
329
- npx @jsonresume/jobs # TUI
367
+ npx @jsonresume/jobs # TUI (registry)
368
+ npx @jsonresume/jobs --resume ./resume.json # TUI (local)
330
369
  npx @jsonresume/jobs search --remote --min-salary 150 # CLI search
331
370
  npx @jsonresume/jobs detail 181420 # Job details
332
371
  npx @jsonresume/jobs mark 181420 interested --feedback "great" # Mark job
@@ -366,7 +405,23 @@ async function main() {
366
405
  case 'tui':
367
406
  case '':
368
407
  default: {
369
- // Default: launch TUI
408
+ // Check for local resume first
409
+ const localResume = await findLocalResume();
410
+ if (localResume) {
411
+ console.error(
412
+ `\n Using local resume: ${localResume.basics?.name || 'Unknown'}`
413
+ );
414
+ console.error(' Marks will be saved locally.\n');
415
+ const { createLocalApiClient } = await import('../src/localApi.js');
416
+ const localApi = createLocalApiClient({
417
+ baseUrl: BASE_URL,
418
+ resume: localResume,
419
+ });
420
+ const { default: runTUI } = await import('../src/tui/App.js');
421
+ return runTUI({ baseUrl: BASE_URL, apiClient: localApi });
422
+ }
423
+
424
+ // Registry mode
370
425
  const apiKey = await getApiKey();
371
426
  const { default: runTUI } = await import('../src/tui/App.js');
372
427
  return runTUI({ baseUrl: BASE_URL, apiKey });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonresume/jobs",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Search Hacker News jobs matched against your JSON Resume",
6
6
  "bin": {
@@ -0,0 +1,66 @@
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 data = await res.json();
29
+ if (!res.ok) throw new Error(data.error || res.statusText);
30
+
31
+ // Overlay local marks onto results
32
+ const marks = getMarks();
33
+ const jobs = (data.jobs || []).map((j) => ({
34
+ ...j,
35
+ state: marks[String(j.id)] || j.state || null,
36
+ }));
37
+
38
+ return { jobs };
39
+ },
40
+
41
+ fetchJobDetail: async (id) => {
42
+ // In local mode we don't have a detail endpoint — return
43
+ // a stub so JobDetail falls back to the job data it already has.
44
+ return { id };
45
+ },
46
+
47
+ markJob: async (id, state) => {
48
+ setMark(id, state);
49
+ return { id, state };
50
+ },
51
+
52
+ fetchMe: async () => ({ resume, username: 'local' }),
53
+
54
+ // Search profiles not supported in local mode
55
+ listSearches: async () => ({ searches: [] }),
56
+ createSearch: async () => {
57
+ throw new Error('Search profiles require a registry account');
58
+ },
59
+ updateSearch: async () => {
60
+ throw new Error('Search profiles require a registry account');
61
+ },
62
+ deleteSearch: async () => {
63
+ throw new Error('Search profiles require a registry account');
64
+ },
65
+ };
66
+ }
@@ -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
@@ -26,11 +26,11 @@ import HelpModal from './HelpModal.js';
26
26
 
27
27
  const TABS = ['all', 'interested', 'applied', 'maybe', 'passed'];
28
28
  const TAB_LABELS = {
29
- all: 'All Jobs',
30
- interested: 'Interested',
31
- applied: '📨 Applied',
32
- maybe: '? Maybe',
33
- passed: 'Passed',
29
+ all: 'All',
30
+ interested: 'Interested',
31
+ applied: 'Applied',
32
+ maybe: 'Maybe',
33
+ passed: 'Passed',
34
34
  };
35
35
 
36
36
  function InlineSearch({ query, onChange, onSubmit }) {
@@ -42,11 +42,11 @@ function InlineSearch({ query, onChange, onSubmit }) {
42
42
  );
43
43
  }
44
44
 
45
- function App({ baseUrl, apiKey }) {
45
+ function App({ baseUrl, apiKey, apiClient }) {
46
46
  const { exit } = useApp();
47
47
  const api = useMemo(
48
- () => createApiClient({ baseUrl, apiKey }),
49
- [baseUrl, apiKey]
48
+ () => apiClient || createApiClient({ baseUrl, apiKey }),
49
+ [baseUrl, apiKey, apiClient]
50
50
  );
51
51
 
52
52
  // View: 'list' | 'detail' | 'filters' | 'searches' | 'ai' | 'help'
@@ -268,7 +268,6 @@ function App({ baseUrl, apiKey }) {
268
268
  reranking,
269
269
  error,
270
270
  aiEnabled: ai.hasKey,
271
- searchName: activeSearch?.name || null,
272
271
  toast: toastEl,
273
272
  });
274
273
 
@@ -390,6 +389,6 @@ function App({ baseUrl, apiKey }) {
390
389
  );
391
390
  }
392
391
 
393
- export default function runTUI({ baseUrl, apiKey }) {
394
- render(h(App, { baseUrl, apiKey }));
392
+ export default function runTUI({ baseUrl, apiKey, apiClient }) {
393
+ render(h(App, { baseUrl, apiKey, apiClient }));
395
394
  }
package/src/tui/Header.js CHANGED
@@ -2,10 +2,10 @@ import { Box, Text } from 'ink';
2
2
  import { h } from './h.js';
3
3
 
4
4
  const FILTER_LABELS = {
5
- remote: (f) => `Remote: ${f.value}`,
6
- search: (f) => `Search: "${f.value}"`,
7
- minSalary: (f) => `Salary ≥ $${f.value}k`,
8
- days: (f) => `Last ${f.value} days`,
5
+ remote: () => 'Remote',
6
+ search: (f) => `"${f.value}"`,
7
+ minSalary: (f) => `≥$${f.value}k`,
8
+ days: (f) => `${f.value}d`,
9
9
  };
10
10
 
11
11
  export default function Header({
@@ -17,71 +17,91 @@ export default function Header({
17
17
  searchName,
18
18
  appliedQuery,
19
19
  }) {
20
+ const cols = process.stdout.columns || 80;
21
+
22
+ // ── Title row ─────────────────────────────────────
23
+ const titleRow = h(
24
+ Box,
25
+ { paddingX: 1, justifyContent: 'space-between' },
26
+ h(
27
+ Box,
28
+ { gap: 1 },
29
+ h(
30
+ Text,
31
+ { bold: true, color: 'black', backgroundColor: 'cyan' },
32
+ ' jsonresume-jobs '
33
+ ),
34
+ searchName
35
+ ? h(Text, { color: 'magenta', bold: true }, ` ${searchName}`)
36
+ : null
37
+ ),
38
+ h(Text, { dimColor: true }, '?:help /:profiles f:filters q:quit')
39
+ );
40
+
41
+ // ── Tab row ───────────────────────────────────────
20
42
  const tabElements = tabs.map((t) => {
21
43
  const active = t === tab;
22
44
  const count = counts[t] || 0;
23
- const label = `${tabLabels[t]} (${count})`;
45
+ const label = `${tabLabels[t]} ${count}`;
46
+
47
+ if (active) {
48
+ return h(
49
+ Box,
50
+ { key: t, marginRight: 1 },
51
+ h(
52
+ Text,
53
+ { bold: true, color: 'black', backgroundColor: 'white' },
54
+ ` ${label} `
55
+ )
56
+ );
57
+ }
24
58
  return h(
25
59
  Box,
26
60
  { key: t, marginRight: 1 },
61
+ h(Text, { dimColor: true }, ` ${label} `)
62
+ );
63
+ });
64
+
65
+ const tabRow = h(Box, { paddingX: 1 }, ...tabElements);
66
+
67
+ // ── Filter pills (only if active) ────────────────
68
+ const tags = [];
69
+ for (const f of filters || []) {
70
+ const label = FILTER_LABELS[f.type]?.(f) || f.value;
71
+ tags.push(
27
72
  h(
28
73
  Text,
29
- {
30
- bold: active,
31
- color: active ? 'cyan' : 'gray',
32
- underline: active,
33
- },
74
+ { key: f.type, color: 'black', backgroundColor: 'yellow' },
34
75
  ` ${label} `
35
76
  )
36
77
  );
37
- });
78
+ }
79
+ if (appliedQuery) {
80
+ tags.push(
81
+ h(
82
+ Text,
83
+ { key: 'find', color: 'black', backgroundColor: 'green' },
84
+ ` find:${appliedQuery} `
85
+ )
86
+ );
87
+ }
38
88
 
39
- const filterTags = (filters || []).map((f, i) => {
40
- const label = FILTER_LABELS[f.type]?.(f) || `${f.type}: ${f.value}`;
41
- return h(Text, { key: i, color: 'yellow' }, ` [${label}] `);
42
- });
89
+ const filterRow =
90
+ tags.length > 0 ? h(Box, { paddingX: 1, gap: 1 }, ...tags) : null;
43
91
 
44
- const hasFilters = filterTags.length > 0 || appliedQuery;
92
+ // ── Divider ───────────────────────────────────────
93
+ const divider = h(
94
+ Box,
95
+ { paddingX: 1 },
96
+ h(Text, { dimColor: true }, '─'.repeat(Math.max(10, cols - 2)))
97
+ );
45
98
 
46
99
  return h(
47
100
  Box,
48
- { flexDirection: 'column', marginBottom: 0 },
49
- h(
50
- Box,
51
- {
52
- paddingX: 1,
53
- borderStyle: 'single',
54
- borderColor: 'cyan',
55
- borderBottom: false,
56
- },
57
- h(Text, { bold: true, color: 'cyan' }, '⚡ '),
58
- h(Text, { bold: true, color: 'white' }, 'JSON Resume Job Search'),
59
- searchName ? h(Text, { color: 'magenta' }, ` 🔍 ${searchName}`) : null,
60
- h(Text, { color: 'gray' }, ' '),
61
- h(Text, { dimColor: true }, 'tab:sections /:searches ?:help')
62
- ),
63
- h(Box, { paddingX: 1, gap: 0 }, ...tabElements),
64
- hasFilters
65
- ? h(
66
- Box,
67
- { paddingX: 1 },
68
- filterTags.length > 0
69
- ? h(Text, { dimColor: true }, 'Filters:')
70
- : null,
71
- ...filterTags,
72
- appliedQuery
73
- ? h(Text, { color: 'yellow' }, ` [Find: "${appliedQuery}"] `)
74
- : null,
75
- h(Text, { dimColor: true }, ' f:manage')
76
- )
77
- : h(
78
- Box,
79
- { paddingX: 1 },
80
- h(
81
- Text,
82
- { dimColor: true },
83
- 'No filters active f:add n:quick search'
84
- )
85
- )
101
+ { flexDirection: 'column' },
102
+ titleRow,
103
+ tabRow,
104
+ filterRow,
105
+ divider
86
106
  );
87
107
  }
@@ -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));
@@ -3,41 +3,33 @@ import { h } from './h.js';
3
3
 
4
4
  const KEYS = {
5
5
  list: [
6
- ['jk', 'nav'],
7
- ['enter', 'open'],
8
- ['i', ''],
9
- ['x', '📨'],
10
- ['m', '?'],
11
- ['p', ''],
6
+ ['j/k', 'nav'],
7
+ ['enter', 'detail'],
8
+ ['i', 'interested'],
9
+ ['x', 'applied'],
10
+ ['m', 'maybe'],
11
+ ['p', 'pass'],
12
12
  ['v', 'select'],
13
+ ['n', 'find'],
13
14
  ['space', 'AI'],
14
- ['f', 'filter'],
15
- ['/', 'search'],
16
- ['e', 'export'],
17
- ['?', 'help'],
18
- ['q', 'quit'],
19
15
  ],
20
16
  detail: [
21
- ['jk', 'nav'],
22
- ['JK', 'scroll detail'],
23
- ['i', ''],
24
- ['x', '📨'],
25
- ['m', '?'],
26
- ['p', '✗'],
27
- ['o', 'open'],
17
+ ['j/k', 'nav jobs'],
18
+ ['J/K', 'scroll'],
19
+ ['i/x/m/p', 'mark'],
20
+ ['o', 'open URL'],
28
21
  ['space', 'AI'],
29
22
  ['esc', 'back'],
30
- ['q', 'close'],
31
23
  ],
32
24
  filters: [
33
- ['jk', 'nav'],
25
+ ['j/k', 'nav'],
34
26
  ['enter', 'edit'],
35
27
  ['a', 'add'],
36
28
  ['d', 'delete'],
37
29
  ['esc', 'close'],
38
30
  ],
39
31
  searches: [
40
- ['jk', 'nav'],
32
+ ['j/k', 'nav'],
41
33
  ['enter', 'switch'],
42
34
  ['n', 'new'],
43
35
  ['d', 'delete'],
@@ -47,6 +39,15 @@ const KEYS = {
47
39
  help: [['?/esc', 'close']],
48
40
  };
49
41
 
42
+ function KeyHint({ k, label }) {
43
+ return h(
44
+ Box,
45
+ { marginRight: 1 },
46
+ h(Text, { color: 'cyan' }, k),
47
+ h(Text, { dimColor: true }, ` ${label}`)
48
+ );
49
+ }
50
+
50
51
  export default function StatusBar({
51
52
  view,
52
53
  jobCount,
@@ -55,54 +56,44 @@ export default function StatusBar({
55
56
  reranking,
56
57
  error,
57
58
  aiEnabled,
58
- searchName,
59
59
  toast,
60
60
  }) {
61
+ const cols = process.stdout.columns || 80;
61
62
  const keys = KEYS[view] || KEYS.list;
62
63
 
63
- const keyElements = keys.map(([key, label], i) =>
64
- h(
65
- Box,
66
- { key: i, marginRight: 1 },
67
- h(Text, { bold: true, color: 'cyan' }, key),
68
- h(Text, { dimColor: true }, `:${label}`)
69
- )
64
+ const divider = h(
65
+ Box,
66
+ { paddingX: 1 },
67
+ h(Text, { dimColor: true }, '─'.repeat(Math.max(10, cols - 2)))
70
68
  );
71
69
 
72
- return h(
70
+ const rightInfo = h(
73
71
  Box,
74
- {
75
- flexDirection: 'column',
76
- borderStyle: 'single',
77
- borderColor: 'gray',
78
- paddingX: 1,
79
- marginTop: 0,
80
- },
81
- toast
82
- ? h(
83
- Box,
84
- { justifyContent: 'space-between' },
85
- toast,
86
- h(
87
- Box,
88
- { gap: 1 },
89
- h(Text, { dimColor: true }, `${jobCount}/${totalCount} jobs`)
90
- )
91
- )
92
- : h(
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')
77
+ );
78
+
79
+ const content = toast
80
+ ? h(Box, { paddingX: 1, justifyContent: 'space-between' }, toast, rightInfo)
81
+ : h(
82
+ Box,
83
+ { paddingX: 1, justifyContent: 'space-between' },
84
+ h(
93
85
  Box,
94
- { justifyContent: 'space-between' },
95
- h(Box, { flexWrap: 'wrap', gap: 0 }, ...keyElements),
96
- h(
97
- Box,
98
- { gap: 1 },
99
- loading ? h(Text, { color: 'yellow' }, '⏳') : null,
100
- reranking ? h(Text, { color: 'magenta' }, '🧠 reranking…') : null,
101
- h(Text, { dimColor: true }, `${jobCount}/${totalCount} jobs`),
102
- searchName ? h(Text, { color: 'magenta' }, '🔍') : null,
103
- aiEnabled ? null : h(Text, { color: 'gray' }, '(no AI)')
104
- )
86
+ { flexWrap: 'wrap' },
87
+ ...keys.map(([k, label], i) => h(KeyHint, { key: i, k, label }))
105
88
  ),
106
- error ? h(Text, { color: 'red' }, `Error: ${error}`) : null
89
+ rightInfo
90
+ );
91
+
92
+ return h(
93
+ Box,
94
+ { flexDirection: 'column' },
95
+ divider,
96
+ content,
97
+ error ? h(Box, { paddingX: 1 }, h(Text, { color: 'red' }, error)) : null
107
98
  );
108
99
  }