SLOPSHOPPER

job-hunt

Your job search in a panel next to Claude Code: an application funnel that flags silent companies, jobs Claude finds and ranks by fit with your resume, and a…

newpaneguardcommandtoasttool
v0.7.0MITupdated 2026-10-15vlle/job-hunt-cc
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · job-hunt
│ ┃ job-hunt ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./panel.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /hunt │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · job-hunt
▣ client module ./panel.tsx
README

job-hunt

Your job search, docked next to Claude Code. Track every application, let Claude find jobs that fit your resume, and tailor the resume for each company. Everything stays in plain JSON in your own folder.

The job-hunt panel docked beside Claude Code: /hunt next, apply and reject update the funnel live

Install

At the Claude Code prompt:

/plugin install job-hunt --marketplace vlle/job-hunt-cc

Answer y to add the marketplace, then pick a scope: user loads it in every session. From a shell it is claude plugin install job-hunt --marketplace vlle/job-hunt-cc. In the desktop app, click + next to the prompt box, then Plugins, Add marketplace with vlle/job-hunt-cc, then Add plugin.

You need Claude Code 2.1.300 or later (claude --version, claude update).

Try it in 10 seconds

/hunt demo

The panel opens with a made-up search: seven applications at every stage, a silent one flagged, six ranked jobs. Nothing is written. Run /hunt demo again, or press p in the panel, to switch back.

Start for real

  1. Open a terminal in the folder with your resume (any empty folder works too) and run claude.
  2. Type /hunt init. Claude finds your resume (PDF, DOCX, Markdown or a LinkedIn export) and writes resume.txt, using only facts from it. Already have a resume.txt? Skip this step: the panel opens on its own.
  3. Press s in the panel. Claude searches the web for jobs that match your resume, opens each posting to check it is still open, and ranks the results by fit.
  4. Press a on a job once you have applied, and n when a company moves you forward. Or just tell Claude: "Acme invited me to an interview".

What the panel shows

  • RESUME: your resume and its tailored copies, their formats, when each was last edited and how many applications used it.
  • APPLIED: the funnel sent › screen › interview › offer, one row per active application with its stage and age. A ⚑ flags any company that has been silent for 21 days.
  • JOBS: openings Claude found for you, ranked by how well they fit your resume. ⌂ marks a fully remote job you can do from your country (as an employee, contractor, B2B or through an EOR); ✈ marks explicit visa sponsorship or relocation.

Searching and tailoring are ordinary Claude turns that the panel queues for you, so they use your session's own tools (web search, scrapers, MCP servers). When a search finishes you get a notification through your terminal's channel (iTerm2, kitty, Ghostty or a bell), the same way Claude Code notifies you. The mod itself only reads and writes a few files in your folder.

Keys and commands

KeyWhereWhat it does
1–9panelselect a row
j / k, arrowspanelmove the selection (after clicking into the panel)
spanelask Claude to search for jobs
ajoblog an application: the job moves to APPLIED
tjobask Claude to tailor the resume for this job
xjobhide the job
ojob or applicationopen the posting in the browser
napplicationmove to the next stage
r / capplicationmark as rejected / close
ppanelswap in made-up demo data (for screenshots), and back

/hunt opens the panel. The same actions work by row number: /hunt init, /hunt demo, /hunt scan, /hunt apply 3, /hunt tailor 3, /hunt hide 3, /hunt open 3, /hunt next 1, /hunt reject 1, /hunt close 1.

Search (s) asks Claude to find up to 20 jobs that match your target, check that each posting still accepts applications, skip companies you already applied to, and save the result. Jobs found in the last three days get a •; jobs older than 30 days drop out.

Tailor (t) asks Claude to create a <company>/ folder modelled on your latest tailored copy, using only verified facts, and to list the requirements your resume does not cover. It does not log the application: press a once you have sent it.

Claude can also call the mod's tools directly whenever you mention your search:

ToolWhat it does
mcp__job-hunt__save_leadsmerges found jobs into hunt/leads.json by url
mcp__job-hunt__log_applicationlogs an application; a repeat for the same company and role, or the same url, updates it
mcp__job-hunt__set_stagemoves an application to screen, interview, offer, rejected or closed

Configuration

Set these in /config (each one is a row there) or under pluginConfigs in your settings:

OptionDefaultUsed for
targetroles that match the resume: abroad with visa sponsorship or relocation, or fully remote from my countrywhat the search looks for, in one line. It also sets the balance between remote and relocation (fully remote; relocation only with sponsorship, as a fallback) and any contract form you need
excludeemptykinds of jobs the search skips, e.g. staff/lead roles, frontend, internships
factsCLAUDE.local.mda file in your folder with verified facts beyond the resume; search and tailoring may use only these and the resume
layoutone listhow JOBS is shown: one list by fit, or relocate first / remote first for two groups; row numbers follow what you see

Your folder

my-job-search/
├── resume.txt            required: marks the folder; the panel reads your profile from it
├── resume.html, *.pdf    optional formats of the base resume
├── resume-web.html       resume-<name>.txt|html|md in the root is a variant called <name>
├── acme/                 a tailored copy per company: resume.* and a PDF
├── CLAUDE.local.md       optional verified facts beyond the resume (see `facts`)
└── hunt/
    ├── applications.json written by the mod and its tools
    └── leads.json        written by save_leads

The panel takes the first three non-empty lines of resume.txt as your name, title and contacts (the first contact, split on ·, • or |, is your location). It also reads the first three Key: a, b, c rows under a SKILLS heading and an English (C1) style level. A resume in another shape still works; the panel just shows less.

Privacy

  • The mod has no network access of its own. It reads your resume files and reads and writes hunt/*.json, nothing else.
  • Search and tailoring run as normal turns in your session, with your model, your tools and your plan's usage.
  • Installed for every session, the mod stays out of the way: outside a folder with resume.txt it adds only the /hunt command, with no tools and no panel.
  • Every write re-reads the file first, and a file that is not valid JSON is never overwritten. Hidden jobs are remembered in applications.json, so a new search does not bring them back.

FAQ

The panel did not open. It opens by itself on a terminal at least 144 columns wide. Below that, type /hunt. After you have opened it once, it docks by itself from 110 columns. On a narrower terminal it sits above the prompt.

Does it work in the desktop app? Yes: the panel draws in the terminal and in the desktop app's Code tab.

Does it apply for me? No. It finds, ranks and tailors; you read the posting and press send.

Development

git clone https://github.com/vlle/job-hunt-cc.git && cd job-hunt-cc
claude plugin validate .
claude plugin test .
claude --plugin-dir .        # hot reload; also lays the API types in .claude-plugin/types/
npx -p typescript tsc -p .   # TypeScript 5.4+, after one interactive run above
  • hooks/register.tsx: hooks, tools, file I/O and the session state ($.state atoms)
  • hooks/hunt.ts: pure logic (tracker, jobs, funnel, variants, prompts)
  • hooks/panel.tsx: the panel, a client module
  • hooks/demo.ts: the made-up data for /hunt demo
  • types/index.d.ts: the state contract

License

MIT

Source 6 files
hooks/register.tsx 613 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookFailure, Register } from 'claude-code'
3
4import {
5  DEFAULT_LAYOUT,
6  EMPTY_FOLDER,
7  EMPTY_LEADS,
8  EMPTY_TRACKER,
9  STAGES,
10  STAGE_LABEL,
11  hideLead,
12  initPromptOf,
13  isDay,
14  isLayout,
15  isStage,
16  jsonTextOf,
17  leadsOf,
18  logApplication,
19  mergeLeads,
20  nextStageOf,
21  patternOf,
22  profileOf,
23  rowCommandOf,
24  rowRefsOf,
25  scanPromptOf,
26  setStage,
27  sortedApplicationsOf,
28  tailorPromptOf,
29  tailoredOf,
30  todayOf,
31  trackerOf,
32  variantsOf,
33  visibleLeadsOf,
34  type Busy,
35  type Change,
36  type Config,
37  type FileEntry,
38  type Folder,
39  type Layout,
40  type PanelProps,
41  type Pending,
42  type Tracker,
43} from './hunt'
44import { demoOf } from './demo'
45
46type Message = { act: string; id: string | null }
47type World = { config: Config; cwd: string; isWatching: boolean }
48type Shown = { folder: Folder; now: number; busy: Busy | null; isDemo: boolean; layout: Layout }
49
50const PANE_ID = 'job-hunt'
51const PANEL_KEY = 'job-hunt:panel'
52const PANE_COLUMNS = 44
53const POLL_MS = 3000
54const MINUTE_MS = 60_000
55const BUSY_LIMIT_MS = 45 * 60_000
56const ROOT_MARKER = 'resume.txt'
57const TRACKER_PATH = 'hunt/applications.json'
58const LEADS_PATH = 'hunt/leads.json'
59const SKIPPED_DIRS = new Set(['hunt', 'node_modules'])
60const DEFAULT_TARGET =
61  'roles that match the resume: abroad with visa sponsorship or relocation, or fully remote from my country'
62const DEFAULT_FACTS = 'CLAUDE.local.md'
63const URL_OPENERS = [['xdg-open'], ['open'], ['rundll32', 'url.dll,FileProtocolHandler']]
64const ACTS = new Set(['init', 'scan', 'anon', 'tailor', 'apply', 'hide', 'open', 'next', 'reject', 'close'])
65const DEMO_ARGS = new Set(['demo', 'anon'])
66const INIT_LABEL = 'Claude is setting up this folder'
67const SCAN_LABEL = 'Claude is searching for jobs'
68const SCAN_DONE = 'search finished, new jobs are in the panel'
69const DEMO_REFUSAL = 'demo data on: actions are off, /hunt demo brings real data back'
70const NO_ROOT = 'no resume.txt here yet: /hunt init sets this folder up, /hunt demo shows made-up data'
71
72const FOLDER = atom({ plugin: 'job-hunt', key: 'folder' } as const, EMPTY_FOLDER)
73const NOW = atom({ plugin: 'job-hunt', key: 'now' } as const, 0)
74const PENDING = atom({ plugin: 'job-hunt', key: 'pending' } as const, null)
75const IS_DEMO = atom({ plugin: 'job-hunt', key: 'isDemo' } as const, false)
76
77const TOOLS = [
78  {
79    name: 'save_leads',
80    isDeferred: false,
81    description:
82      'Saves found jobs to hunt/leads.json and shows them in the job-hunt panel. Merges with earlier scans by url: a repeated job is updated, jobs older than 30 days drop out. Only real open jobs with a direct link.',
83    inputSchema: {
84      type: 'object',
85      properties: {
86        query: { type: 'string', description: 'what was searched and where, one line' },
87        leads: {
88          type: 'array',
89          items: {
90            type: 'object',
91            properties: {
92              company: { type: 'string' },
93              title: { type: 'string', description: 'job title as in the posting' },
94              url: { type: 'string', description: 'direct link to the posting (http/https)' },
95              location: { type: 'string', description: 'city and work format, as in the posting' },
96              country: { type: 'string', description: 'office country code: UK, DE, NL…; EU or WW for remote' },
97              sponsor: { type: 'boolean', description: 'the posting explicitly mentions a visa, sponsorship or relocation' },
98              remote: {
99                type: 'boolean',
100                description:
101                  'fully remote and workable from the candidate country (the resume location) under any contract form: worldwide, a region that includes it, or as a contractor/B2B; remote limited to a country or region the candidate does not live in, or to holders of a work permit they lack, is false',
102              },
103              salary: { type: 'string', description: 'salary range, if stated' },
104              posted: { type: 'string', description: 'publication date YYYY-MM-DD' },
105              fit: { type: 'number', minimum: 0, maximum: 100, description: 'match with the resume' },
106              why: { type: 'string', description: 'why this fit and what is missing, in English, up to 90 chars' },
107            },
108            required: ['company', 'title', 'url', 'fit'],
109          },
110        },
111      },
112      required: ['leads'],
113    },
114  },
115  {
116    name: 'log_application',
117    isDeferred: false,
118    description:
119      'Logs a sent application in hunt/applications.json (stage "applied"). A repeated call for the same company and role or the same url updates the entry instead of duplicating it.',
120    inputSchema: {
121      type: 'object',
122      properties: {
123        company: { type: 'string' },
124        role: { type: 'string' },
125        url: { type: 'string' },
126        resume: { type: 'string', description: 'which resume variant was sent: resume.txt or a folder such as acme/' },
127        on: { type: 'string', description: 'application date YYYY-MM-DD, today by default' },
128        note: { type: 'string', description: 'short note in English' },
129      },
130      required: ['company', 'role'],
131    },
132  },
133  {
134    name: 'set_stage',
135    isDeferred: false,
136    description:
137      'Moves an application through the funnel: applied → screen → interview → offer, or rejected / closed. ref is the application id or the company name.',
138    inputSchema: {
139      type: 'object',
140      properties: {
141        ref: { type: 'string' },
142        stage: { type: 'string', enum: [...STAGES] },
143        on: { type: 'string', description: 'date YYYY-MM-DD, today by default' },
144        note: { type: 'string', description: 'short note in English' },
145      },
146      required: ['ref', 'stage'],
147    },
148  },
149]
150
151export const register: Register = (on, options) => {
152  const world: World = {
153    config: {
154      target: optionOf(options.target, DEFAULT_TARGET),
155      exclude: optionOf(options.exclude, ''),
156      facts: optionOf(options.facts, DEFAULT_FACTS),
157      layout: isLayout(options.layout) ? options.layout : DEFAULT_LAYOUT,
158    },
159    cwd: '',
160    isWatching: false,
161  }
162
163  on('session.start', async ($, e, next) => {
164    world.cwd = e.cwd
165    await $.command
166      .register({
167        name: 'hunt',
168        description: 'job-hunt panel: applications, jobs that fit your resume, tailored resumes; init sets a folder up, demo shows made-up data',
169        argumentHint: '[init | demo | scan | apply|tailor|hide|open|next|reject|close <row>]',
170      })
171      .catch(() => undefined)
172
173    const root = await rootOf($, e.cwd)
174    await update($, FOLDER, current => (current.root === root ? current : { ...EMPTY_FOLDER, root }))
175    if (root) {
176      await registerTools($)
177    }
178
179    const poll = async () => {
180      try {
181        await refresh($, world)
182      } catch (error) {
183        $.ui.log(`job-hunt: polling ${world.cwd} failed: ${String(error)}`, { to: 'debug' })
184      }
185      $.clock.after(POLL_MS, () => void poll())
186    }
187    await poll()
188    if (root) {
189      void showPane($, false)
190    }
191
192    return next(e)
193  })
194
195  on('command.run', { command: 'hunt' }, async ($, e) => {
196    const args = e.args.trim()
197    if (DEMO_ARGS.has(args)) {
198      await toggleDemo($)
199      await showPane($, true)
200
201      return {}
202    }
203    if (args === '') {
204      world.isWatching = true
205      await showPane($, true)
206
207      return {}
208    }
209    if (args === 'init') {
210      const { root } = await read($, FOLDER)
211      if (root) {
212        return { text: `job-hunt: this folder is set up already, see ${root}/${ROOT_MARKER}` }
213      }
214      world.isWatching = true
215      $.clock.after(0, () => void dispatch($, world, { act: 'init', id: null }))
216      await showPane($, true)
217
218      return {}
219    }
220    if (await read($, IS_DEMO)) {
221      return { text: `job-hunt: ${DEMO_REFUSAL}` }
222    }
223    const folder = await read($, FOLDER)
224    if (!folder.root) {
225      return { text: `job-hunt: ${NO_ROOT}` }
226    }
227    if (args === 'scan') {
228      $.clock.after(0, () => void dispatch($, world, { act: 'scan', id: null }))
229
230      return {}
231    }
232    const visible = visibleLeadsOf(folder.leads, folder.tracker, world.config.layout)
233    const refs = rowRefsOf(sortedApplicationsOf(folder.tracker.applications), visible)
234    const command = rowCommandOf(args, refs)
235    if ('error' in command) {
236      return { text: `job-hunt: ${command.error}` }
237    }
238    $.clock.after(0, () => void dispatch($, world, command))
239
240    return {}
241  })
242
243  on('turn.start', async ($, e, next) => {
244    const entry = await read($, PENDING)
245    if (entry && entry.turnId === null && e.agentId === undefined && e.text.includes(entry.text)) {
246      await update($, PENDING, current => (current && e.text.includes(current.text) ? { ...current, turnId: e.turnId } : current))
247    }
248
249    return next(e)
250  })
251
252  on('turn.complete', async ($, e, next) => {
253    const result = await next(e)
254    const entry = await read($, PENDING)
255    if (entry && e.agentId === undefined && entry.turnId === e.turnId) {
256      await settle($, entry, entry.busy.kind === 'tailor')
257      await refresh($, world)
258    }
259
260    return result
261  })
262
263  on('ui.message', async ($, e, next) => {
264    if (e.element !== PANEL_KEY) {
265      return next(e)
266    }
267    const message = messageOf(e.data)
268    if (message?.act === 'anon') {
269      await toggleDemo($)
270    } else if (message) {
271      await dispatch($, world, message)
272    }
273
274    return {}
275  })
276
277  on('tool.call', { tool: 'mcp__job-hunt__save_leads' }, async ($, e) => {
278    const { root } = await read($, FOLDER)
279    if (!root) {
280      return { deny: 'job-hunt: resume.txt not found, nowhere to save' }
281    }
282    const current = leadsOf(await $.fs.read(`${root}/${LEADS_PATH}`).catch(() => ''))
283    if (!current) {
284      return { deny: `${LEADS_PATH} is not valid JSON — fix the file, the mod will not overwrite it` }
285    }
286    const incoming = Array.isArray(e.leads) ? e.leads : []
287    const query = typeof e.query === 'string' && e.query.trim() !== '' ? e.query.trim() : null
288    const merged = mergeLeads(current, incoming, query, await $.clock.now())
289    await $.fs.write(`${root}/${LEADS_PATH}`, jsonTextOf(merged.leads))
290    await refresh($, world)
291    const folder = await read($, FOLDER)
292    const shown = visibleLeadsOf(folder.leads, folder.tracker, world.config.layout).length
293
294    return {
295      result: `saved to ${LEADS_PATH}: +${merged.added} new, ${merged.updated} updated, ${merged.skipped} skipped without company/title/url; ${shown} in the panel`,
296    }
297  }).catch(($, e, next) => ({ deny: failureOf('save_leads', next.error) }))
298
299  on('tool.call', { tool: 'mcp__job-hunt__log_application' }, async ($, e) => {
300    const company = typeof e.company === 'string' ? e.company.trim() : ''
301    const role = typeof e.role === 'string' ? e.role.trim() : ''
302    const { root } = await read($, FOLDER)
303    if (!root || !company || !role) {
304      return { deny: root ? 'company and role are required' : 'job-hunt: resume.txt not found' }
305    }
306    const input = {
307      company,
308      role,
309      url: typeof e.url === 'string' ? e.url : null,
310      resume: typeof e.resume === 'string' ? e.resume : null,
311      on: typeof e.on === 'string' ? e.on : null,
312      note: typeof e.note === 'string' ? e.note : null,
313    }
314    const outcome = await mutate($, world, (fresh, today) => logApplication(fresh, input, today))
315
316    return typeof outcome === 'string'
317      ? { deny: outcome }
318      : { result: `logged application ${outcome.id} (${STAGE_LABEL.applied}) in ${TRACKER_PATH}` }
319  }).catch(($, e, next) => ({ deny: failureOf('log_application', next.error) }))
320
321  on('tool.call', { tool: 'mcp__job-hunt__set_stage' }, async ($, e) => {
322    const ref = typeof e.ref === 'string' ? e.ref.trim() : ''
323    const stage = e.stage
324    const { root } = await read($, FOLDER)
325    if (!root || !ref || !isStage(stage)) {
326      return { deny: root ? `ref and stage (one of ${STAGES.join(', ')}) are required` : 'job-hunt: resume.txt not found' }
327    }
328    const note = typeof e.note === 'string' ? e.note : null
329    const day = isDay(e.on) ? e.on : null
330    const outcome = await mutate($, world, (fresh, today) => setStage(fresh, ref, stage, day ?? today, note))
331
332    return typeof outcome === 'string' ? { deny: outcome } : { result: `${outcome.id} → ${STAGE_LABEL[stage]}` }
333  }).catch(($, e, next) => ({ deny: failureOf('set_stage', next.error) }))
334
335  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
336    if (e.surface !== 'terminal' && e.surface !== 'desktop') {
337      return next(e)
338    }
339
340    const { Client } = $.ui.resolve(e)
341    const [folder, now, pending, isDemo] = await Promise.all([read($, FOLDER), read($, NOW), read($, PENDING), read($, IS_DEMO)])
342    const shown: Shown = { folder, now, busy: pending?.busy ?? null, isDemo, layout: world.config.layout }
343
344    return (
345      <Client
346        key={PANEL_KEY}
347        module="./panel.tsx"
348        props={panelPropsOf(shown, e.props.bodyColumns, e.props.placement === 'dock')}
349      />
350    )
351  })
352}
353
354function panelPropsOf(shown: Shown, columns: number, isDocked: boolean): PanelProps {
355  const { folder, now, layout } = shown
356  if (shown.isDemo) {
357    const demo = demoOf(now)
358    const leads = visibleLeadsOf({ ...EMPTY_LEADS, leads: demo.leads }, EMPTY_TRACKER, layout)
359
360    return { now, hasRoot: true, busy: null, columns, isDocked, layout, ...demo, leads }
361  }
362
363  return {
364    now,
365    hasRoot: folder.root !== '',
366    profile: folder.profile,
367    variants: variantsOf(folder.files, folder.tracker.applications),
368    applications: sortedApplicationsOf(folder.tracker.applications),
369    leads: visibleLeadsOf(folder.leads, folder.tracker, layout),
370    scanned_ms: folder.leads.scanned_ms,
371    layout,
372    busy: shown.busy,
373    columns,
374    isDocked,
375  }
376}
377
378async function rootOf($: EngineInterface, cwd: string): Promise<string> {
379  const candidates = [cwd, cwd.replace(/\/[^/]+$/, ''), cwd.replace(/(\/[^/]+){2}$/, '')]
380  for (const candidate of candidates) {
381    if (candidate && (await $.fs.exists(`${candidate}/${ROOT_MARKER}`).catch(() => false))) {
382      return candidate
383    }
384  }
385
386  return ''
387}
388
389async function registerTools($: EngineInterface): Promise<void> {
390  await Promise.all(TOOLS.map(tool => $.tool.register(tool).catch(() => undefined)))
391}
392
393async function showPane($: EngineInterface, isAsked: boolean): Promise<void> {
394  await $.ui
395    .open({ id: PANE_ID, title: 'job-hunt', columns: PANE_COLUMNS, ...(isAsked ? { focus: true } : {}) })
396    .catch(() => undefined)
397}
398
399async function readFiles($: EngineInterface, root: string): Promise<FileEntry[]> {
400  const entries = await $.fs.list(root).catch(() => [])
401  const dirs = entries.filter(entry => entry.kind === 'dir' && !entry.name.startsWith('.') && !SKIPPED_DIRS.has(entry.name))
402  const nested = await Promise.all(
403    dirs.map(async dir => (await $.fs.list(`${root}/${dir.name}`).catch(() => [])).map(entry => ({ ...entry, dir: dir.name }))),
404  )
405  const candidates = [...entries.map(entry => ({ ...entry, dir: '' })), ...nested.flat()].filter(
406    entry => entry.kind === 'file' && /^resume|\.pdf$/i.test(entry.name),
407  )
408
409  return Promise.all(
410    candidates.map(async entry => {
411      const relative = entry.dir ? `${entry.dir}/${entry.name}` : entry.name
412      const stat = await $.fs.stat(`${root}/${relative}`).catch(() => undefined)
413
414      return { dir: entry.dir, name: entry.name, mtime_ms: stat?.mtimeMs ?? 0 }
415    }),
416  )
417}
418
419async function refresh($: EngineInterface, world: World): Promise<void> {
420  const now = await $.clock.now()
421  if (Math.floor((await read($, NOW)) / MINUTE_MS) !== Math.floor(now / MINUTE_MS)) {
422    await update($, NOW, () => now)
423  }
424
425  const current = await read($, FOLDER)
426  const root = current.root || (world.isWatching ? await rootOf($, world.cwd) : '')
427  if (!root) {
428    return
429  }
430  if (!current.root) {
431    await registerTools($)
432  }
433  const [trackerText, leadsText, resumeText, files] = await Promise.all([
434    $.fs.read(`${root}/${TRACKER_PATH}`).catch(() => ''),
435    $.fs.read(`${root}/${LEADS_PATH}`).catch(() => ''),
436    $.fs.read(`${root}/${ROOT_MARKER}`).catch(() => ''),
437    readFiles($, root),
438  ])
439  const folder: Folder = {
440    root,
441    profile: profileOf(resumeText),
442    files,
443    tracker: trackerOf(trackerText) ?? current.tracker,
444    leads: leadsOf(leadsText) ?? current.leads,
445  }
446  if (JSON.stringify(folder) !== JSON.stringify(current)) {
447    await update($, FOLDER, () => folder)
448  }
449
450  const entry = await read($, PENDING)
451  if (entry?.busy.kind === 'scan' && (folder.leads.scanned_ms ?? 0) > entry.busy.since_ms) {
452    await settle($, entry, true)
453  } else if (entry && now - entry.busy.since_ms > BUSY_LIMIT_MS) {
454    await settle($, entry, false)
455  }
456}
457
458async function mutate(
459  $: EngineInterface,
460  world: World,
461  change: (fresh: Tracker, today: string) => Change | Tracker | { error: string },
462): Promise<string | { id: string | null }> {
463  const { root } = await read($, FOLDER)
464  const path = `${root}/${TRACKER_PATH}`
465  const fresh = trackerOf(await $.fs.read(path).catch(() => ''))
466  if (!fresh) {
467    return `${TRACKER_PATH} is not valid JSON — fix it by hand, the mod will not overwrite it`
468  }
469  const outcome = change(fresh, todayOf(await $.clock.now()))
470  if ('error' in outcome) {
471    return outcome.error
472  }
473  const tracker = 'tracker' in outcome ? outcome.tracker : outcome
474  await $.fs.write(path, jsonTextOf(tracker))
475  await update($, FOLDER, current => ({ ...current, tracker }))
476  await refresh($, world)
477
478  return { id: 'application' in outcome ? outcome.application.id : null }
479}
480
481async function startBusy($: EngineInterface, busy: Omit<Busy, 'since_ms'>, text: string, done: string | null): Promise<void> {
482  const running = await read($, PENDING)
483  if (running) {
484    $.ui.toast(`job-hunt: already running: ${running.busy.label}`)
485
486    return
487  }
488  const entry: Pending = { busy: { ...busy, since_ms: await $.clock.now() }, text, turnId: null, done }
489  await update($, PENDING, () => entry)
490  void $.prompt
491    .submit({ text })
492    .then(result => {
493      if (result.drop !== undefined) {
494        void settle($, entry, false)
495      }
496    })
497    .catch(() => void settle($, entry, false))
498}
499
500async function settle($: EngineInterface, entry: Pending, isDone: boolean): Promise<void> {
501  const current = await read($, PENDING)
502  if (current?.text !== entry.text || current.busy.since_ms !== entry.busy.since_ms) {
503    return
504  }
505  await update($, PENDING, () => null)
506  if (isDone && entry.done) {
507    const sent = await $.ui.notify(entry.done).catch(() => undefined)
508    if (!sent?.isSent) {
509      $.ui.toast(`job-hunt: ${entry.done}`)
510    }
511  }
512}
513
514async function toggleDemo($: EngineInterface): Promise<void> {
515  const isOn = !(await read($, IS_DEMO))
516  await update($, IS_DEMO, () => isOn)
517  $.ui.toast(isOn ? 'job-hunt: demo data on, real data hidden' : 'job-hunt: real data back')
518}
519
520async function dispatch($: EngineInterface, world: World, message: Message): Promise<void> {
521  const error = await perform($, world, message)
522  if (error) {
523    $.ui.toast(`job-hunt: ${error}`)
524  }
525}
526
527async function perform($: EngineInterface, world: World, message: Message): Promise<string | undefined> {
528  if (await read($, IS_DEMO)) {
529    return DEMO_REFUSAL
530  }
531  const folder = await read($, FOLDER)
532  if (message.act === 'init') {
533    world.isWatching = true
534    if (!folder.root) {
535      await startBusy($, { kind: 'init', label: INIT_LABEL }, initPromptOf(world.cwd), null)
536    }
537
538    return undefined
539  }
540  if (!folder.root) {
541    return NO_ROOT
542  }
543  const id = message.id ?? ''
544  const lead = folder.leads.leads.find(item => item.id === id || `lead:${item.id}` === id)
545  const app = folder.tracker.applications.find(item => item.id === id || `app:${item.id}` === id)
546  const variants = variantsOf(folder.files, folder.tracker.applications)
547  const failed = (outcome: string | { id: string | null }) => (typeof outcome === 'string' ? outcome : undefined)
548
549  switch (message.act) {
550    case 'scan':
551      await startBusy($, { kind: 'scan', label: SCAN_LABEL }, scanPromptOf(world.config, folder.tracker), SCAN_DONE)
552      return undefined
553    case 'tailor':
554      if (lead) {
555        const prompt = tailorPromptOf(lead, patternOf(variants), world.config.facts)
556        const done = `the resume for ${lead.company} is tailored`
557        await startBusy($, { kind: 'tailor', label: `Claude is tailoring the resume for ${lead.company}` }, prompt, done)
558      }
559      return undefined
560    case 'apply': {
561      if (!lead) {
562        return undefined
563      }
564      const input = { company: lead.company, role: lead.title, url: lead.url, resume: tailoredOf(lead, variants), note: lead.why }
565      return failed(await mutate($, world, (fresh, today) => logApplication(fresh, input, today)))
566    }
567    case 'hide':
568      return lead ? failed(await mutate($, world, fresh => hideLead(fresh, lead.id))) : undefined
569    case 'next': {
570      const stage = app ? nextStageOf(app.stage) : undefined
571      return app && stage ? failed(await mutate($, world, (fresh, today) => setStage(fresh, app.id, stage, today))) : undefined
572    }
573    case 'reject':
574      return app ? failed(await mutate($, world, (fresh, today) => setStage(fresh, app.id, 'rejected', today))) : undefined
575    case 'close':
576      return app ? failed(await mutate($, world, (fresh, today) => setStage(fresh, app.id, 'closed', today))) : undefined
577    case 'open': {
578      const url = id.startsWith('app:') ? app?.url : lead?.url
579      return url ? openUrl($, url) : undefined
580    }
581    default:
582      return undefined
583  }
584}
585
586async function openUrl($: EngineInterface, url: string): Promise<string | undefined> {
587  for (const opener of URL_OPENERS) {
588    const result = await $.process.run([...opener, url], { timeoutMs: 10_000 }).catch(() => undefined)
589    if (result?.exitCode === 0) {
590      return undefined
591    }
592  }
593
594  return `could not open ${url}: no xdg-open, open or rundll32 worked`
595}
596
597function failureOf(tool: string, error: HookFailure): string {
598  return `job-hunt: ${tool} failed: ${error.message ?? error.kind}`
599}
600
601function optionOf(value: unknown, fallback: string): string {
602  return typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback
603}
604
605function messageOf(data: unknown): Message | undefined {
606  if (typeof data !== 'object' || data === null) {
607    return undefined
608  }
609  const { act, id } = data as { act?: unknown; id?: unknown }
610
611  return typeof act === 'string' && ACTS.has(act) ? { act, id: typeof id === 'string' ? id : null } : undefined
612}
613
hooks/hunt.ts 535 lines
1import type { Application, Busy, FileEntry, Folder, Lead, Leads, Profile, Stage, Tracker } from '../types'
2
3export type { Application, Busy, FileEntry, Folder, Lead, Leads, Pending, Profile, Stage, Step, Tracker } from '../types'
4
5export type Variant = { name: string; path: string; formats: string[]; updated_ms: number; sent: number }
6
7export type Layout = 'one list' | 'relocate first' | 'remote first'
8
9export type Config = { target: string; exclude: string; facts: string; layout: Layout }
10
11export type PanelProps = {
12  now: number
13  hasRoot: boolean
14  profile: Profile | null
15  variants: Variant[]
16  applications: Application[]
17  leads: Lead[]
18  scanned_ms: number | null
19  layout: Layout
20  busy: Busy | null
21  columns: number
22  isDocked: boolean
23}
24
25export type ApplicationInput = {
26  company: string
27  role: string
28  url?: string | null
29  resume?: string | null
30  on?: string | null
31  note?: string | null
32}
33
34export type Change = { tracker: Tracker; application: Application }
35
36export const DAY_MS = 86_400_000
37export const SILENCE_DAYS = 21
38export const FRESH_MS = 3 * DAY_MS
39export const STAGES: readonly Stage[] = ['applied', 'screen', 'interview', 'offer', 'rejected', 'closed']
40export const FUNNEL: readonly Stage[] = ['applied', 'screen', 'interview', 'offer']
41export const STAGE_LABEL: Record<Stage, string> = {
42  applied: 'applied',
43  screen: 'screen',
44  interview: 'interview',
45  offer: 'offer',
46  rejected: 'rejected',
47  closed: 'closed',
48}
49export const EMPTY_TRACKER: Tracker = { v: 1, applications: [], hidden: [] }
50export const EMPTY_LEADS: Leads = { v: 1, scanned_ms: null, query: null, leads: [] }
51export const EMPTY_FOLDER: Folder = { root: '', profile: null, files: [], tracker: EMPTY_TRACKER, leads: EMPTY_LEADS }
52export const LAYOUTS: readonly Layout[] = ['one list', 'relocate first', 'remote first']
53export const DEFAULT_LAYOUT: Layout = 'one list'
54export const USAGE = 'usage: /hunt [init | demo | scan | apply|tailor|hide|open|next|reject|close <row>]'
55
56const LEAD_TTL_MS = 30 * DAY_MS
57const SCAN_LIMIT = 20
58const REMOTE_REGIONS = new Set(['EU', 'WW'])
59const REMOTE_PLACE = /remote|home[\s-]?based/i
60const ROW_KINDS: Record<string, readonly string[]> = {
61  apply: ['lead'],
62  tailor: ['lead'],
63  hide: ['lead'],
64  open: ['app', 'lead'],
65  next: ['app'],
66  reject: ['app'],
67  close: ['app'],
68}
69const RESUME_FILE = /^resume(?:-([\w-]+))?\.(txt|html|md)$/i
70const PDF_FILE = /\.pdf$/i
71const FORMAT_ORDER = ['txt', 'html', 'md', 'pdf']
72const SKILL_ROWS = 3
73const SKILLS_HEAD = /^(?:technical |core |key )?skills:?$/i
74const SECTION_HEAD = /^[A-Z][A-Z &/-]{2,}$/
75const CONTACT_SEPARATOR = /\s+[·•|]\s+/
76const SHORT_SKILL: Record<string, string> = {
77  PostgreSQL: 'PG',
78  'Apache Kafka': 'Kafka',
79  ClickHouse: 'CH',
80  Kubernetes: 'k8s',
81}
82
83export function isStage(value: unknown): value is Stage {
84  return typeof value === 'string' && (STAGES as readonly string[]).includes(value)
85}
86
87export function isLayout(value: unknown): value is Layout {
88  return typeof value === 'string' && (LAYOUTS as readonly string[]).includes(value)
89}
90
91export function isDay(value: unknown): value is string {
92  return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
93}
94
95export function idOf(text: string): string {
96  return text
97    .toLowerCase()
98    .replace(/[^\p{L}\p{N}]+/gu, '-')
99    .replace(/^-+|-+$/g, '')
100}
101
102export function urlKeyOf(url: string): string {
103  return url
104    .trim()
105    .replace(/[?#].*$/, '')
106    .replace(/\/+$/, '')
107    .replace(/^https?:\/\/(www\.)?/i, '')
108    .toLowerCase()
109}
110
111export function todayOf(ms: number): string {
112  return new Date(ms).toISOString().slice(0, 10)
113}
114
115export function daysSinceOf(day: string, now: number): number {
116  const at = Date.parse(`${day}T00:00:00Z`)
117
118  return Number.isFinite(at) ? Math.max(0, Math.floor((now - at) / DAY_MS)) : 0
119}
120
121export function shortDayOf(day: string | null): string {
122  return isDay(day) ? `${day.slice(8, 10)}.${day.slice(5, 7)}` : '—'
123}
124
125export function trackerOf(text: string): Tracker | undefined {
126  if (text.trim() === '') {
127    return EMPTY_TRACKER
128  }
129  const raw = recordOf(jsonOf(text))
130  if (!raw) {
131    return undefined
132  }
133  const applications = arrayOf(raw.applications)
134    .map(applicationOf)
135    .filter((app): app is Application => app !== undefined)
136  const hidden = arrayOf(raw.hidden).filter((id): id is string => typeof id === 'string')
137
138  return { v: 1, applications, hidden }
139}
140
141export function leadsOf(text: string): Leads | undefined {
142  if (text.trim() === '') {
143    return EMPTY_LEADS
144  }
145  const raw = recordOf(jsonOf(text))
146  if (!raw) {
147    return undefined
148  }
149  const leads = arrayOf(raw.leads)
150    .map(value => leadOf(value, 0))
151    .filter((lead): lead is Lead => lead !== undefined)
152
153  return {
154    v: 1,
155    scanned_ms: typeof raw.scanned_ms === 'number' ? raw.scanned_ms : null,
156    query: textOf(raw.query),
157    leads,
158  }
159}
160
161export function leadOf(value: unknown, now: number): Lead | undefined {
162  const raw = recordOf(value)
163  const company = textOf(raw?.company)
164  const title = textOf(raw?.title)
165  const url = textOf(raw?.url)
166  if (!raw || !company || !title || !url || !/^https?:\/\//i.test(url)) {
167    return undefined
168  }
169  const fit = typeof raw.fit === 'number' && Number.isFinite(raw.fit) ? Math.round(Math.min(100, Math.max(0, raw.fit))) : 0
170  const location = textOf(raw.location) ?? ''
171  const country = (textOf(raw.country) ?? '').toUpperCase().slice(0, 3)
172
173  return {
174    id: urlKeyOf(url),
175    company,
176    title,
177    url,
178    location,
179    country,
180    sponsor: raw.sponsor === true,
181    remote: typeof raw.remote === 'boolean' ? raw.remote : isRemoteAbroad(country, location),
182    salary: textOf(raw.salary),
183    posted: isDay(raw.posted) ? raw.posted : null,
184    fit,
185    why: textOf(raw.why),
186    found_ms: typeof raw.found_ms === 'number' ? raw.found_ms : now,
187  }
188}
189
190export function jsonTextOf(value: Tracker | Leads): string {
191  return `${JSON.stringify(value, null, 2)}\n`
192}
193
194export function isActive(app: Application): boolean {
195  return FUNNEL.includes(app.stage)
196}
197
198export function lastDayOf(app: Application): string | null {
199  return app.history.at(-1)?.on ?? null
200}
201
202export function isSilent(app: Application, now: number): boolean {
203  const last = lastDayOf(app)
204
205  return isActive(app) && app.stage !== 'offer' && last !== null && daysSinceOf(last, now) >= SILENCE_DAYS
206}
207
208export function nextStageOf(stage: Stage): Stage | undefined {
209  const index = FUNNEL.indexOf(stage)
210
211  return index >= 0 && index < FUNNEL.length - 1 ? FUNNEL[index + 1] : undefined
212}
213
214export function funnelOf(apps: readonly Application[]): { stage: Stage; count: number }[] {
215  const isReached = (app: Application, stage: Stage) => {
216    const rank = FUNNEL.indexOf(stage)
217
218    return [app.stage, ...app.history.map(step => step.stage)].some(seen => FUNNEL.indexOf(seen) >= rank)
219  }
220
221  return FUNNEL.map(stage => ({ stage, count: apps.filter(app => isReached(app, stage)).length }))
222}
223
224export function sortedApplicationsOf(apps: readonly Application[]): Application[] {
225  const rank = (app: Application) => (isActive(app) ? FUNNEL.indexOf(app.stage) : -1)
226
227  return [...apps].sort((a, b) => rank(b) - rank(a) || (lastDayOf(b) ?? '').localeCompare(lastDayOf(a) ?? ''))
228}
229
230export function rowRefsOf(sorted: readonly Application[], leads: readonly Lead[]): string[] {
231  return [...sorted.filter(isActive).map(app => `app:${app.id}`), ...leads.map(lead => `lead:${lead.id}`)]
232}
233
234export function rowCommandOf(args: string, refs: readonly string[]): { act: string; id: string } | { error: string } {
235  const [act = '', row = '', ...rest] = args.split(/\s+/)
236  const kinds = ROW_KINDS[act]
237  if (!kinds || rest.length > 0 || !/^\d+$/.test(row)) {
238    return { error: USAGE }
239  }
240  const id = refs[Number(row) - 1]
241  if (!id) {
242    return { error: `no row ${row}` }
243  }
244  const kind = id.slice(0, id.indexOf(':'))
245  if (!kinds.includes(kind)) {
246    return { error: `row ${row} is ${kind === 'app' ? 'an application' : 'a job'}, ${act} works on ${kind === 'app' ? 'jobs' : 'applications'}` }
247  }
248
249  return { act, id }
250}
251
252export function logApplication(tracker: Tracker, input: ApplicationInput, today: string): Change {
253  const url = textOf(input.url)
254  const key = url ? urlKeyOf(url) : null
255  const id = idOf(`${input.company} ${input.role}`)
256  const existing = tracker.applications.find(app => app.id === id || (key !== null && app.url !== null && urlKeyOf(app.url) === key))
257  const application: Application = existing
258    ? {
259        ...existing,
260        url: existing.url ?? url,
261        resume: textOf(input.resume) ?? existing.resume,
262        note: textOf(input.note) ?? existing.note,
263      }
264    : {
265        id,
266        company: input.company.trim(),
267        role: input.role.trim(),
268        url,
269        resume: textOf(input.resume),
270        stage: 'applied',
271        history: [{ stage: 'applied', on: isDay(input.on) ? input.on : today }],
272        note: textOf(input.note),
273      }
274  const applications = existing
275    ? tracker.applications.map(app => (app === existing ? application : app))
276    : [...tracker.applications, application]
277
278  return { tracker: { ...tracker, applications }, application }
279}
280
281export function setStage(
282  tracker: Tracker,
283  ref: string,
284  stage: Stage,
285  on: string,
286  note?: string | null,
287): Change | { error: string } {
288  const matches = applicationsOf(tracker, ref)
289  const [found] = matches
290  if (!found) {
291    return { error: `no application "${ref}"` }
292  }
293  if (matches.length > 1) {
294    return { error: `"${ref}" is ambiguous: ${matches.map(app => app.id).join(', ')}` }
295  }
296  const application: Application = {
297    ...found,
298    stage,
299    history: found.stage === stage ? found.history : [...found.history, { stage, on }],
300    note: textOf(note) ?? found.note,
301  }
302
303  return {
304    tracker: { ...tracker, applications: tracker.applications.map(app => (app === found ? application : app)) },
305    application,
306  }
307}
308
309export function hideLead(tracker: Tracker, id: string): Tracker {
310  return tracker.hidden.includes(id) ? tracker : { ...tracker, hidden: [...tracker.hidden, id] }
311}
312
313export function visibleLeadsOf(leads: Leads, tracker: Tracker, layout: Layout): Lead[] {
314  const hidden = new Set(tracker.hidden)
315  const applied = new Set(tracker.applications.flatMap(app => (app.url ? [app.id, urlKeyOf(app.url)] : [app.id])))
316  const groupOf = (lead: Lead) =>
317    layout === 'relocate first' ? Number(lead.remote) : layout === 'remote first' ? Number(!lead.remote) : 0
318
319  return leads.leads
320    .filter(lead => !hidden.has(lead.id) && !applied.has(lead.id) && !applied.has(idOf(`${lead.company} ${lead.title}`)))
321    .sort((a, b) => groupOf(a) - groupOf(b) || b.fit - a.fit || (b.posted ?? '').localeCompare(a.posted ?? ''))
322}
323
324export function mergeLeads(
325  current: Leads,
326  incoming: readonly unknown[],
327  query: string | null,
328  now: number,
329): { leads: Leads; added: number; updated: number; skipped: number } {
330  const known = new Map(current.leads.map(lead => [lead.id, lead]))
331  const fresh = new Map<string, Lead>()
332  let skipped = 0
333
334  for (const value of incoming) {
335    const lead = leadOf(value, now)
336    if (!lead) {
337      skipped += 1
338      continue
339    }
340    fresh.set(lead.id, { ...lead, found_ms: known.get(lead.id)?.found_ms ?? fresh.get(lead.id)?.found_ms ?? now })
341  }
342
343  const kept = current.leads.filter(lead => !fresh.has(lead.id) && now - lead.found_ms < LEAD_TTL_MS)
344  const added = [...fresh.keys()].filter(id => !known.has(id)).length
345
346  return {
347    leads: { v: 1, scanned_ms: now, query: query ?? current.query, leads: [...fresh.values(), ...kept] },
348    added,
349    updated: fresh.size - added,
350    skipped,
351  }
352}
353
354export function profileOf(text: string): Profile | null {
355  const lines = text.split('\n').map(line => line.trim())
356  const [name, title, contacts] = lines.filter(Boolean)
357  if (!name || !title) {
358    return null
359  }
360  const start = lines.findIndex(line => SKILLS_HEAD.test(line))
361  const end = lines.findIndex((line, index) => index > start && SECTION_HEAD.test(line))
362  const block = start < 0 ? [] : lines.slice(start + 1, end > start ? end : undefined)
363  const skills = block
364    .filter(line => line.indexOf(':') > 0)
365    .slice(0, SKILL_ROWS)
366    .flatMap(line => line.slice(line.indexOf(':') + 1).split(','))
367    .map(item => item.replace(/\(.*?\)/g, '').trim())
368    .filter(item => /^[A-Z]/.test(item))
369    .map(item => SHORT_SKILL[item] ?? item)
370  const english = /English \(([A-C][12])\)/.exec(text)?.[1]
371
372  return {
373    name,
374    title,
375    place: contacts?.split(CONTACT_SEPARATOR)[0]?.trim() || null,
376    skills: [...new Set(skills)],
377    english: english ? `EN ${english}` : null,
378  }
379}
380
381export function variantsOf(files: readonly FileEntry[], apps: readonly Application[]): Variant[] {
382  const groups = new Map<string, { name: string; dir: string; files: FileEntry[] }>()
383
384  for (const file of files) {
385    const match = RESUME_FILE.exec(file.name)
386    if (!match || (file.dir !== '' && match[1])) {
387      continue
388    }
389    const name = file.dir === '' ? (match[1] ?? 'base') : file.dir
390    const group = groups.get(name) ?? { name, dir: file.dir, files: [] }
391    group.files.push(file)
392    groups.set(name, group)
393  }
394
395  for (const file of files) {
396    const group = file.dir === '' ? groups.get('base') : groups.get(file.dir)
397    if (PDF_FILE.test(file.name) && group && group.dir === file.dir) {
398      group.files.push(file)
399    }
400  }
401
402  const sentOf = (keys: string[]) =>
403    apps.filter(app => app.resume !== null && keys.includes(app.resume.replace(/\/+$/, '').toLowerCase())).length
404
405  return [...groups.values()]
406    .map(group => {
407      const formats = [...new Set(group.files.map(file => file.name.split('.').pop()?.toLowerCase() ?? ''))].sort(
408        (a, b) => FORMAT_ORDER.indexOf(a) - FORMAT_ORDER.indexOf(b),
409      )
410      const own = group.files.filter(file => RESUME_FILE.test(file.name))
411      const main = own.find(file => file.name.endsWith('.txt')) ?? own[0]
412      const path = group.dir === '' ? (main?.name ?? '') : `${group.dir}/`
413
414      return {
415        name: group.name,
416        path,
417        formats,
418        updated_ms: Math.max(0, ...group.files.map(file => file.mtime_ms)),
419        sent: sentOf([group.name.toLowerCase(), path.replace(/\/+$/, '').toLowerCase()]),
420      }
421    })
422    .sort((a, b) => (a.name === 'base' ? -1 : b.name === 'base' ? 1 : b.updated_ms - a.updated_ms))
423}
424
425export function patternOf(variants: readonly Variant[]): string | null {
426  return variants.filter(variant => variant.path.endsWith('/')).sort((a, b) => b.updated_ms - a.updated_ms)[0]?.path ?? null
427}
428
429export function tailoredOf(lead: Lead, variants: readonly Variant[]): string | null {
430  const folder = `${idOf(lead.company)}/`
431
432  return variants.find(variant => variant.path === folder || variant.path.startsWith(`${idOf(lead.company)}-`))?.path ?? null
433}
434
435export function scanPromptOf(config: Config, tracker: Tracker): string {
436  const applied = [...new Set(tracker.applications.map(app => app.company))]
437  const facts = config.facts ? `, verified facts about me are in ${config.facts}` : ''
438
439  return [
440    `Find jobs I should apply to: ${config.target}.`,
441    `The resume is resume.txt${facts}. Search the web with the tools you have and the public ATS APIs (Greenhouse, Lever, Ashby); open every posting and make sure it still accepts applications.`,
442    `Find up to ${SCAN_LIMIT} jobs that match the target, best matches first. Follow the target on how much remote work and relocation it wants; do not split the list evenly between them.`,
443    ...(config.exclude ? [`Skip ${config.exclude}.`] : []),
444    'sponsor=true only when the posting explicitly mentions a visa, sponsorship or relocation.',
445    'remote=true only when the posting lets me work from my country of residence (the resume location) without moving — worldwide, a region that includes my country, or as a contractor or B2B. The employer does not need an entity or payroll in my country: any contract form counts.',
446    'Remote limited to a country or region I do not live in, or to holders of a work permit I do not have (such as remote EU or remote UK), is remote=false.',
447    `Already applied to: ${applied.length > 0 ? applied.join(', ') : 'nowhere yet'} — do not suggest these companies.`,
448    'fit 0–100 is how well the job matches the resume (stack, level, domain); why says briefly why and what is missing, in English.',
449    `Save the result with a single mcp__job-hunt__save_leads call, at most ${SCAN_LIMIT} jobs; reply in chat with a short summary.`,
450  ].join('\n')
451}
452
453export function tailorPromptOf(lead: Lead, pattern: string | null, facts: string): string {
454  const folder = `${idOf(lead.company)}/`
455  const source = facts ? `the resume and ${facts}` : 'the resume'
456
457  return [
458    `Tailor the resume for ${lead.company} — ${lead.title}: ${lead.url}`,
459    pattern
460      ? `Read the whole posting and create the folder ${folder} following ${pattern}: resume.html, resume.txt, build.sh, a PDF of exactly one A4 page.`
461      : `Read the whole posting and create the folder ${folder}: resume.html, resume.txt and a PDF of exactly one A4 page.`,
462    `Only verified facts from ${source}: mirror the wording of the posting where it is true, never add experience that does not exist.`,
463    'At the end, list the requirements of the posting that the resume does not cover. Do not log the application — the panel button does that.',
464  ].join('\n')
465}
466
467export function initPromptOf(folder: string): string {
468  return [
469    `Set up this folder for the job-hunt panel: ${folder}`,
470    'Find my resume here: a PDF, DOCX, Markdown, HTML or text file, or a LinkedIn profile export. If there is none, ask me to paste my resume or give a path to it, and wait for my answer.',
471    'Write it to resume.txt as plain text: my name on the first line, my job title on the second, contacts on the third separated by " · " with my city first, then sections under UPPERCASE headings, one of them SKILLS with rows like "Languages: Go, SQL".',
472    'Use only facts from the source, never add anything. Do not touch the original file.',
473    'Then ask me in one line which jobs I am looking for (role, location or remote, visa needs) and tell me I can save it as the job-hunt "target" option in /config.',
474    'Finish by telling me to press s in the job-hunt panel to search for jobs.',
475  ].join('\n')
476}
477
478function applicationsOf(tracker: Tracker, ref: string): Application[] {
479  const key = idOf(ref)
480  const exact = tracker.applications.filter(app => app.id === ref || app.id === key)
481
482  return exact.length > 0
483    ? exact
484    : tracker.applications.filter(app => idOf(app.company) === key || app.id.startsWith(`${key}-`))
485}
486
487function applicationOf(value: unknown): Application | undefined {
488  const raw = recordOf(value)
489  const company = textOf(raw?.company)
490  if (!raw || !company) {
491    return undefined
492  }
493  const role = textOf(raw.role) ?? ''
494  const history = arrayOf(raw.history)
495    .map(recordOf)
496    .filter((step): step is Record<string, unknown> => step !== undefined)
497    .filter(step => isStage(step.stage) && isDay(step.on))
498    .map(step => ({ stage: step.stage as Stage, on: step.on as string }))
499
500  return {
501    id: textOf(raw.id) ?? idOf(`${company} ${role}`),
502    company,
503    role,
504    url: textOf(raw.url),
505    resume: textOf(raw.resume),
506    stage: isStage(raw.stage) ? raw.stage : (history.at(-1)?.stage ?? 'applied'),
507    history,
508    note: textOf(raw.note),
509  }
510}
511
512function isRemoteAbroad(country: string, location: string): boolean {
513  return REMOTE_REGIONS.has(country) && REMOTE_PLACE.test(location)
514}
515
516function recordOf(value: unknown): Record<string, unknown> | undefined {
517  return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
518}
519
520function arrayOf(value: unknown): unknown[] {
521  return Array.isArray(value) ? value : []
522}
523
524function textOf(value: unknown): string | null {
525  return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
526}
527
528function jsonOf(text: string): unknown {
529  try {
530    return JSON.parse(text)
531  } catch {
532    return undefined
533  }
534}
535
hooks/demo.ts 178 lines
1import {
2  DAY_MS,
3  idOf,
4  sortedApplicationsOf,
5  todayOf,
6  variantsOf,
7  type Application,
8  type FileEntry,
9  type Lead,
10  type PanelProps,
11  type Stage,
12} from './hunt'
13
14type Demo = Pick<PanelProps, 'profile' | 'variants' | 'applications' | 'leads' | 'scanned_ms'>
15type DemoApplication = Pick<Application, 'company' | 'role' | 'resume' | 'note'> & { steps: readonly (readonly [Stage, number])[] }
16type DemoLead = Omit<Lead, 'id' | 'url' | 'posted' | 'found_ms'> & { postedDaysAgo: number; foundDaysAgo: number }
17
18const HOUR_MS = 3_600_000
19const JOBS_URL = 'https://example.com/jobs'
20const PDF = 'Alex-Doe-Backend-Engineer.pdf'
21const BASE_DAYS_AGO = 2
22const FOLDERS: readonly (readonly [string, number])[] = [
23  ['hooli', 21],
24  ['globex', 12],
25  ['initech', 9],
26  ['pied-piper', 25],
27]
28
29const APPLICATIONS: readonly DemoApplication[] = [
30  {
31    company: 'Hooli',
32    role: 'Senior Backend Engineer, Payments',
33    resume: 'hooli/',
34    note: 'offer call on Friday',
35    steps: [['applied', 21], ['screen', 17], ['interview', 10], ['offer', 1]],
36  },
37  {
38    company: 'Globex',
39    role: 'Backend Engineer (Go)',
40    resume: 'globex/',
41    note: 'system design round booked',
42    steps: [['applied', 12], ['screen', 8], ['interview', 3]],
43  },
44  {
45    company: 'Initech',
46    role: 'Software Engineer, Core Services',
47    resume: 'initech/',
48    note: 'recruiter call went well',
49    steps: [['applied', 9], ['screen', 4]],
50  },
51  { company: 'Acme', role: 'Go Developer, Platform', resume: 'resume.txt', note: null, steps: [['applied', 2]] },
52  { company: 'Pied Piper', role: 'Backend Engineer, Storage', resume: 'pied-piper/', note: null, steps: [['applied', 25]] },
53  {
54    company: 'Umbrella',
55    role: 'Backend Engineer',
56    resume: 'resume.txt',
57    note: 'went with a more senior candidate',
58    steps: [['applied', 30], ['screen', 24], ['rejected', 18]],
59  },
60  { company: 'Soylent', role: 'Go Engineer', resume: 'resume.txt', note: 'role filled internally', steps: [['applied', 40], ['closed', 20]] },
61]
62
63const LEADS: readonly DemoLead[] = [
64  {
65    company: 'Vandelay',
66    title: 'Backend Engineer, Logistics',
67    location: 'London, hybrid',
68    country: 'UK',
69    sponsor: true,
70    remote: false,
71    salary: '£80–100k',
72    fit: 88,
73    why: 'Go, Kafka and Postgres match; AWS is a gap',
74    postedDaysAgo: 3,
75    foundDaysAgo: 1,
76  },
77  {
78    company: 'Cyberdyne',
79    title: 'Senior Go Engineer',
80    location: 'Amsterdam',
81    country: 'NL',
82    sponsor: true,
83    remote: false,
84    salary: '€75–90k',
85    fit: 79,
86    why: 'strong Go and k8s overlap; wants deeper gRPC',
87    postedDaysAgo: 5,
88    foundDaysAgo: 1,
89  },
90  {
91    company: 'Tyrell',
92    title: 'Platform Engineer (Go)',
93    location: 'Berlin, hybrid',
94    country: 'DE',
95    sponsor: true,
96    remote: false,
97    salary: null,
98    fit: 67,
99    why: 'platform focus; Terraform is missing',
100    postedDaysAgo: 8,
101    foundDaysAgo: 6,
102  },
103  {
104    company: 'Stark',
105    title: 'Go Backend Engineer',
106    location: 'Dublin',
107    country: 'IE',
108    sponsor: true,
109    remote: false,
110    salary: null,
111    fit: 46,
112    why: 'heavy AWS and DynamoDB; level may be too senior',
113    postedDaysAgo: 14,
114    foundDaysAgo: 9,
115  },
116  {
117    company: 'Aperture',
118    title: 'Go Engineer, Billing',
119    location: 'Remote, worldwide',
120    country: 'WW',
121    sponsor: false,
122    remote: true,
123    salary: '$90–110k',
124    fit: 71,
125    why: 'contractor through an EOR; Go and Postgres match',
126    postedDaysAgo: 4,
127    foundDaysAgo: 1,
128  },
129  {
130    company: 'Wonka',
131    title: 'Backend Engineer, Checkout',
132    location: 'Remote, EMEA',
133    country: 'EU',
134    sponsor: false,
135    remote: true,
136    salary: '€60–75k',
137    fit: 58,
138    why: 'hires across EMEA; stack is Go plus MongoDB',
139    postedDaysAgo: 11,
140    foundDaysAgo: 6,
141  },
142]
143
144export function demoOf(now: number): Demo {
145  const dayOf = (daysAgo: number) => todayOf(now - daysAgo * DAY_MS)
146  const applications = APPLICATIONS.map(({ steps, ...demo }): Application => {
147    const id = `demo-${idOf(demo.company)}`
148    const history = steps.map(([stage, daysAgo]) => ({ stage, on: dayOf(daysAgo) }))
149
150    return { ...demo, id, url: `${JOBS_URL}/${id}`, stage: history.at(-1)?.stage ?? 'applied', history }
151  })
152  const leads = LEADS.map(({ postedDaysAgo, foundDaysAgo, ...demo }): Lead => {
153    const url = `${JOBS_URL}/${idOf(`${demo.company} ${demo.title}`)}`
154
155    return { ...demo, id: url.replace(/^https:\/\//, ''), url, posted: dayOf(postedDaysAgo), found_ms: now - foundDaysAgo * DAY_MS }
156  })
157  const fileOf = (dir: string, name: string, daysAgo: number): FileEntry => ({ dir, name, mtime_ms: now - daysAgo * DAY_MS })
158  const files = [
159    ...['resume.txt', 'resume.html', PDF].map(name => fileOf('', name, BASE_DAYS_AGO)),
160    fileOf('', 'resume-web.html', 15),
161    ...FOLDERS.flatMap(([dir, daysAgo]) => ['resume.txt', 'resume.html', PDF].map(name => fileOf(dir, name, daysAgo))),
162  ]
163
164  return {
165    profile: {
166      name: 'Alex Doe',
167      title: 'Backend Software Engineer — Go',
168      place: null,
169      skills: ['Go', 'SQL', 'PG', 'Redis', 'Kafka', 'CH', 'k8s'],
170      english: 'EN C1',
171    },
172    variants: variantsOf(files, applications),
173    applications: sortedApplicationsOf(applications),
174    leads,
175    scanned_ms: now - 3 * HOUR_MS,
176  }
177}
178
hooks/panel.tsx 519 lines
1import type { ClientElements, ClientModule, RenderElement } from 'claude-code'
2
3import {
4  DEFAULT_LAYOUT,
5  FRESH_MS,
6  STAGE_LABEL,
7  daysSinceOf,
8  funnelOf,
9  isActive,
10  isSilent,
11  lastDayOf,
12  nextStageOf,
13  shortDayOf,
14  type Application,
15  type Layout,
16  type Lead,
17  type PanelProps,
18  type Stage,
19} from './hunt'
20import { ACCENT, SILENCE, SPINNER, STAGE_LOOK, agoOf, dayOf, fitBarOf, fitOf, minutesOf, type Cell } from './paint'
21
22type Item = { id: string; application: Application; lead?: undefined } | { id: string; lead: Lead; application?: undefined }
23type Box = { tick: number; pickId: string | null; pickIndex: number; ids: string[]; isBusy: boolean }
24type State = { tick: number; box: Box }
25type Post = (act: string, id?: string) => void
26type Pick = (id: string, index: number) => void
27type LeadGroup = { label: string | null; note: string; isRemote: boolean | null }
28type ShownGroup = LeadGroup & { count: number; shown: Lead[] }
29
30const TICK_MS = 100
31const MIN_COLUMNS = 30
32const HOTKEYS = '123456789'
33const ANON_KEY = 'p'
34const INLINE_LEADS = 4
35const RELOCATE: LeadGroup = { label: 'RELOCATE', note: 'visa or move', isRemote: false }
36const REMOTE: LeadGroup = { label: 'REMOTE', note: 'from home', isRemote: true }
37const LEAD_GROUPS: Record<Layout, readonly LeadGroup[]> = {
38  'one list': [{ label: null, note: '', isRemote: null }],
39  'relocate first': [RELOCATE, REMOTE],
40  'remote first': [REMOTE, RELOCATE],
41}
42const CLOSE_COLUMNS = 2
43const FIT_COLUMNS = 5
44const COMPANY_COLUMNS = 10
45const INDENT = '    '
46const FUNNEL_LABEL: Partial<Record<Stage, string>> = { applied: 'sent', screen: 'screen', interview: 'interview', offer: 'offer' }
47
48const Panel: ClientModule<PanelProps, State> = (given, surface) => {
49  const props: PanelProps = { ...given, layout: given.layout ?? DEFAULT_LAYOUT }
50  let box = surface.state?.box
51  if (!box) {
52    const created: Box = { tick: 0, pickId: null, pickIndex: 0, ids: [], isBusy: false }
53    surface.every(TICK_MS, () => {
54      if (created.isBusy) {
55        created.tick += 1
56        surface.setState({ tick: created.tick, box: created })
57      }
58    })
59    surface.onKey(event => {
60      if (event.key === ANON_KEY) {
61        surface.post({ act: 'anon' })
62
63        return
64      }
65      const step = event.key === 'j' || event.key === 'down' ? 1 : event.key === 'k' || event.key === 'up' ? -1 : 0
66      if (step !== 0 && created.ids.length > 0) {
67        const index = Math.min(created.ids.length - 1, Math.max(0, indexOf(created) + step))
68        created.pickId = created.ids[index] ?? null
69        created.pickIndex = index
70        surface.setState({ tick: created.tick, box: created })
71      }
72    })
73    surface.setState({ tick: 0, box: created })
74    box = created
75  }
76
77  const state = box
78  const width = Math.max(MIN_COLUMNS, surface.columns > 0 ? surface.columns : props.columns)
79  const ui = surface.elements
80  const { Box: Column, Text } = ui
81  const post: Post = (act, id) => surface.post(id === undefined ? { act } : { act, id })
82  const pick: Pick = (id, index) => {
83    state.pickId = id
84    state.pickIndex = index
85    surface.setState({ tick: state.tick, box: state })
86  }
87
88  const active = props.applications.filter(isActive)
89  const closed = props.applications.filter(app => !isActive(app))
90  const layoutGroups = LEAD_GROUPS[props.layout] ?? LEAD_GROUPS[DEFAULT_LAYOUT]
91  const groups: ShownGroup[] = layoutGroups.map(group => {
92    const all = props.leads.filter(lead => group.isRemote === null || lead.remote === group.isRemote)
93    const inline = Math.floor(INLINE_LEADS / layoutGroups.length)
94
95    return { ...group, count: all.length, shown: props.isDocked ? all : all.slice(0, inline) }
96  })
97  const isRemoteMarked = layoutGroups.length === 1
98  const leads = groups.flatMap(group => group.shown)
99  const items: Item[] = [
100    ...active.map(application => ({ id: `app:${application.id}`, application })),
101    ...leads.map(lead => ({ id: `lead:${lead.id}`, lead })),
102  ]
103  state.isBusy = props.busy !== null
104  state.ids = items.map(item => item.id)
105  const selected = props.isDocked || state.pickId !== null ? items[indexOf(state)] : undefined
106
107  const rows: RenderElement[] = [headOf(ui, props, state.tick, state.isBusy, width, active.length)]
108  if (props.busy) {
109    rows.push(
110      <Text color={ACCENT}>{fitOf(`${props.busy.label}… ${minutesOf(props.now - props.busy.since_ms)}`, width)}</Text>,
111    )
112  }
113
114  if (!props.hasRoot) {
115    rows.push(<Text> </Text>, ...welcomeOf(ui, width, props.busy !== null, post))
116
117    return <Column flexDirection="column" width={width}>{rows}</Column>
118  }
119
120  if (props.isDocked) {
121    rows.push(<Text> </Text>, ...resumeOf(ui, props, width))
122  }
123
124  rows.push(<Text> </Text>, ...applicationsOf(ui, props, active, closed, width))
125  active.forEach((application, index) => {
126    const isSelected = selected?.application === application
127    rows.push(applicationRowOf(ui, application, index, isSelected, props.now, width, pick))
128    if (isSelected) {
129      rows.push(...applicationDetailOf(ui, application, width, post))
130    }
131  })
132  if (closed.length > 0) {
133    rows.push(closedOf(ui, closed, width))
134  }
135
136  rows.push(<Text> </Text>, leadsHeadOf(ui, props, width, post))
137  if (leads.length === 0) {
138    rows.push(<Text dimColor wrap="wrap"> empty — press s and Claude searches for jobs that fit the resume</Text>)
139  } else {
140    let first = active.length
141    for (const group of groups) {
142      if (group.label !== null) {
143        rows.push(groupHeadOf(ui, group))
144      }
145      group.shown.forEach((lead, offset) => {
146        const index = active.length + leads.indexOf(lead)
147        const isSelected = selected?.lead === lead
148        const onPick = () => pick(`lead:${lead.id}`, index)
149        rows.push(leadRowOf(ui, lead, first + offset, isSelected, isRemoteMarked, props.now, width, onPick))
150        if (isSelected) {
151          rows.push(...leadDetailOf(ui, lead, width, props.busy !== null, post))
152        }
153      })
154      first += group.count
155    }
156  }
157  if (!props.isDocked && props.leads.length > leads.length) {
158    rows.push(<Text dimColor>{` ${props.leads.length - leads.length} more in the dock on the right`}</Text>)
159  }
160
161  return <Column flexDirection="column" width={width}>{rows}</Column>
162}
163
164export default Panel
165
166function indexOf(box: Box): number {
167  const found = box.pickId === null ? -1 : box.ids.indexOf(box.pickId)
168
169  return found >= 0 ? found : Math.max(0, Math.min(box.pickIndex, box.ids.length - 1))
170}
171
172function lineOf(ui: ClientElements, width: number, left: RenderElement[], right?: RenderElement): RenderElement {
173  const { Box } = ui
174
175  return (
176    <Box width={width} justifyContent="space-between">
177      <Box>{left}</Box>
178      {right ?? null}
179    </Box>
180  )
181}
182
183function headOf(ui: ClientElements, props: PanelProps, tick: number, isBusy: boolean, width: number, activeCount: number): RenderElement {
184  const { Text } = ui
185  const glyph = isBusy ? SPINNER[tick % SPINNER.length] : '◆'
186
187  return lineOf(
188    ui,
189    width - CLOSE_COLUMNS,
190    [<Text color={ACCENT}>{glyph}</Text>, <Text bold> job-hunt</Text>, <Text dimColor>{` · ${activeCount} active`}</Text>],
191    <Text dimColor>{dayOf(props.now)}</Text>,
192  )
193}
194
195function welcomeOf(ui: ClientElements, width: number, isBusy: boolean, post: Post): RenderElement[] {
196  const { Button, Text } = ui
197  const noteOf = (text: string) => wrapOf(text, width - INDENT.length, 3).map(line => <Text dimColor>{`${INDENT}${line}`}</Text>)
198
199  return [
200    <Text bold>No resume.txt in this folder yet.</Text>,
201    <Text> </Text>,
202    <Button key="hunt:init" hotkey="i" plain dimColor={isBusy} onPress={() => post('init')}>set up this folder</Button>,
203    ...noteOf('Claude turns your resume (PDF, DOCX, Markdown or a LinkedIn export) into resume.txt'),
204    <Text> </Text>,
205    <Button key="hunt:demo" hotkey="d" plain onPress={() => post('anon')}>try the demo</Button>,
206    ...noteOf('made-up data, nothing is written; p switches back'),
207  ]
208}
209
210function sectionOf(ui: ClientElements, title: string, note: string): RenderElement[] {
211  const { Text } = ui
212
213  return [<Text bold color={ACCENT}>{title}</Text>, <Text dimColor>{`  ${note}`}</Text>]
214}
215
216function resumeOf(ui: ClientElements, props: PanelProps, width: number): RenderElement[] {
217  const { Box, Text } = ui
218  const profile = props.profile
219  const title = profile ? profile.title : 'resume.txt is unreadable'
220  const rows: RenderElement[] = [
221    <Box>
222      <Text bold color={ACCENT}>RESUME</Text>
223      <Text bold>{`  ${fitOf(title, width - 8).trimEnd()}`}</Text>
224    </Box>,
225  ]
226  const variants = props.variants
227  const nameWidth = Math.min(14, Math.max(6, ...variants.map(variant => [...variant.name].length)))
228  const formatWidth = Math.max(3, width - nameWidth - 12)
229  const newest = Math.max(0, ...variants.map(variant => variant.updated_ms))
230
231  for (const variant of variants) {
232    const isNewest = variant.updated_ms === newest
233    rows.push(
234      <Box>
235        <Text> </Text>
236        <Text bold={isNewest} color={isNewest ? ACCENT : undefined}>{fitOf(variant.name, nameWidth)}</Text>
237        <Text> </Text>
238        <Text dimColor>{fitOf(variant.formats.join(' '), formatWidth)}</Text>
239        <Text> </Text>
240        <Text dimColor>{dayOf(variant.updated_ms)}</Text>
241        <Text color="cyan">{(variant.sent > 0 ? `→${variant.sent}` : '').padStart(4)}</Text>
242      </Box>,
243    )
244  }
245
246  if (profile && profile.skills.length > 0) {
247    const tail = profile.english ? ` · ${profile.english}` : ''
248    rows.push(<Text dimColor>{fitOf(` ${profile.skills.join(' ')}${tail}`, width)}</Text>)
249  }
250
251  return rows
252}
253
254function applicationsOf(
255  ui: ClientElements,
256  props: PanelProps,
257  active: Application[],
258  closed: Application[],
259  width: number,
260): RenderElement[] {
261  const { Box, Text } = ui
262  const silent = active.filter(app => isSilent(app, props.now)).length
263  const rows: RenderElement[] = [
264    lineOf(
265      ui,
266      width,
267      sectionOf(ui, 'APPLIED', `${active.length} active · ${closed.length} closed`),
268      silent > 0 ? <Text color={SILENCE}>{`⚑ ${silent} silent`}</Text> : undefined,
269    ),
270  ]
271
272  if (props.isDocked && props.applications.length > 0) {
273    const steps = funnelOf(props.applications).flatMap((step, index) => [
274      <Text dimColor>{`${index > 0 ? ' › ' : ' '}${FUNNEL_LABEL[step.stage] ?? step.stage} `}</Text>,
275      step.count > 0
276        ? <Text bold color={STAGE_LOOK[step.stage].color}>{String(step.count)}</Text>
277        : <Text dimColor>0</Text>,
278    ])
279    rows.push(<Box>{steps}</Box>)
280  }
281
282  if (active.length === 0) {
283    rows.push(<Text dimColor> nothing active — press a on a job to log an application</Text>)
284  }
285
286  return rows
287}
288
289function buttonOf(ui: ClientElements, key: string, index: number, label: string, onPress: () => void): RenderElement[] {
290  const { Button, Text } = ui
291  const hotkey = HOTKEYS[index]
292
293  return hotkey
294    ? [<Button key={key} hotkey={hotkey} plain onPress={onPress}>{label}</Button>]
295    : [<Text>{'   '}</Text>, <Button key={key} plain onPress={onPress}>{label}</Button>]
296}
297
298function markerOf(ui: ClientElements, isSelected: boolean, isFresh: boolean): RenderElement {
299  const { Text } = ui
300
301  return <Text color={ACCENT} bold>{isSelected ? '›' : isFresh ? '•' : ' '}</Text>
302}
303
304function applicationRowOf(
305  ui: ClientElements,
306  app: Application,
307  index: number,
308  isSelected: boolean,
309  now: number,
310  width: number,
311  pick: Pick,
312): RenderElement {
313  const { Box, Text } = ui
314  const look = STAGE_LOOK[app.stage]
315  const days = daysSinceOf(lastDayOf(app) ?? '', now)
316  const isQuiet = isSilent(app, now)
317  const status = isQuiet ? `⚑ ${days}d` : `${STAGE_LABEL[app.stage]} ${days}d`
318  const labelWidth = Math.max(8, width - 1 - 3 - 1 - 2 - [...status].length)
319  const label = fitOf(`${fitOf(app.company, COMPANY_COLUMNS)} ${app.role}`, labelWidth)
320  const id = `app:${app.id}`
321
322  return (
323    <Box>
324      {markerOf(ui, isSelected, false)}
325      {buttonOf(ui, `hunt:${id}`, index, label, () => pick(id, index))}
326      <Text> </Text>
327      <Text color={look.color}>{look.glyph}</Text>
328      <Text color={isQuiet ? SILENCE : undefined} dimColor={!isQuiet}>{` ${status}`}</Text>
329    </Box>
330  )
331}
332
333function applicationDetailOf(ui: ClientElements, app: Application, width: number, post: Post): RenderElement[] {
334  const { Text } = ui
335  const next = nextStageOf(app.stage)
336  const trail = app.history.map(step => `${shortDayOf(step.on)} ${STAGE_LABEL[step.stage]}`).join(' → ')
337  const extra = [app.resume ? `resume ${app.resume}` : '', app.note ?? ''].filter(Boolean).join(' · ')
338  const rows: RenderElement[] = [<Text dimColor>{fitOf(`${INDENT}${trail}`, width)}</Text>]
339
340  if (extra) {
341    rows.push(<Text dimColor>{fitOf(`${INDENT}${extra}`, width)}</Text>)
342  }
343
344  rows.push(
345    actionsOf(ui, [
346      next ? { key: 'next', hotkey: 'n', label: `→ ${STAGE_LABEL[next]}`, onPress: () => post('next', app.id) } : undefined,
347      { key: 'reject', hotkey: 'r', label: 'rejected', onPress: () => post('reject', app.id) },
348    ]),
349    actionsOf(ui, [
350      { key: 'close', hotkey: 'c', label: 'close', onPress: () => post('close', app.id) },
351      app.url ? { key: 'open-app', hotkey: 'o', label: 'open', onPress: () => post('open', `app:${app.id}`) } : undefined,
352    ]),
353  )
354
355  return rows
356}
357
358function closedOf(ui: ClientElements, closed: Application[], width: number): RenderElement {
359  const { Box, Text } = ui
360  const parts: RenderElement[] = [<Text> </Text>]
361  let used = 1
362
363  for (const [index, app] of closed.entries()) {
364    const look = STAGE_LOOK[app.stage]
365    const text = ` ${app.company}  `
366    const rest = closed.length - index
367    if (used + 1 + [...text].length > width - 4 && rest > 0) {
368      parts.push(<Text dimColor>{`+${rest}`}</Text>)
369      break
370    }
371    parts.push(<Text color={look.color} dimColor>{look.glyph}</Text>, <Text dimColor>{text}</Text>)
372    used += 1 + [...text].length
373  }
374
375  return <Box>{parts}</Box>
376}
377
378function leadsHeadOf(ui: ClientElements, props: PanelProps, width: number, post: Post): RenderElement {
379  const { Button } = ui
380  const scanned = props.scanned_ms === null ? 'not scanned yet' : `scanned ${agoOf(props.scanned_ms, props.now)}`
381
382  return lineOf(
383    ui,
384    width,
385    sectionOf(ui, 'JOBS', `${props.leads.length} · ${scanned}`),
386    <Button key="hunt:scan" hotkey="s" plain dimColor={props.busy !== null} onPress={() => post('scan')}>search</Button>,
387  )
388}
389
390function groupHeadOf(ui: ClientElements, group: ShownGroup): RenderElement {
391  const { Box, Text } = ui
392
393  return (
394    <Box>
395      <Text bold>{` ${group.label}`}</Text>
396      <Text dimColor>{`  ${group.count} · ${group.note}`}</Text>
397    </Box>
398  )
399}
400
401function leadRowOf(
402  ui: ClientElements,
403  lead: Lead,
404  row: number,
405  isSelected: boolean,
406  isRemoteMarked: boolean,
407  now: number,
408  width: number,
409  onPick: () => void,
410): RenderElement {
411  const { Box, Text } = ui
412  const mark = lead.remote && isRemoteMarked ? '⌂' : lead.sponsor ? '✈' : ' '
413  const labelWidth = Math.max(8, width - 1 - 3 - 1 - FIT_COLUMNS - 1 - 4)
414  const label = fitOf(`${fitOf(lead.company, COMPANY_COLUMNS)} ${lead.title}`, labelWidth)
415
416  return (
417    <Box>
418      {markerOf(ui, isSelected, now - lead.found_ms < FRESH_MS)}
419      {buttonOf(ui, `hunt:lead:${lead.id}`, row, label, onPick)}
420      <Text> </Text>
421      {cellsOf(ui, fitBarOf(lead.fit, FIT_COLUMNS))}
422      <Text dimColor>{` ${fitOf(lead.country, 3)}`}</Text>
423      <Text color={ACCENT}>{mark}</Text>
424    </Box>
425  )
426}
427
428function leadDetailOf(ui: ClientElements, lead: Lead, width: number, isBusy: boolean, post: Post): RenderElement[] {
429  const { Text } = ui
430  const facts = [`fit ${lead.fit}`, lead.salary ?? '', lead.location, lead.posted ? shortDayOf(lead.posted) : '']
431    .filter(Boolean)
432    .join(' · ')
433  const rows: RenderElement[] = wrapOf(facts, width - INDENT.length, 2).map(line => <Text>{`${INDENT}${line}`}</Text>)
434
435  for (const line of wrapOf(lead.why ?? '', width - INDENT.length, 2)) {
436    rows.push(<Text dimColor>{`${INDENT}${line}`}</Text>)
437  }
438
439  rows.push(
440    actionsOf(ui, [
441      { key: 'apply', hotkey: 'a', label: 'applied', onPress: () => post('apply', lead.id) },
442      { key: 'tailor', hotkey: 't', label: 'tailor', dimColor: isBusy, onPress: () => post('tailor', lead.id) },
443    ]),
444    actionsOf(ui, [
445      { key: 'hide', hotkey: 'x', label: 'hide', onPress: () => post('hide', lead.id) },
446      { key: 'open-lead', hotkey: 'o', label: 'open', onPress: () => post('open', `lead:${lead.id}`) },
447    ]),
448  )
449
450  return rows
451}
452
453type Action = { key: string; hotkey: string; label: string; dimColor?: boolean; onPress: () => void }
454
455function actionsOf(ui: ClientElements, actions: (Action | undefined)[]): RenderElement {
456  const { Box, Button, Text } = ui
457  const parts: RenderElement[] = [<Text>{INDENT}</Text>]
458
459  for (const action of actions) {
460    if (!action) {
461      continue
462    }
463    if (parts.length > 1) {
464      parts.push(<Text>{'   '}</Text>)
465    }
466    parts.push(
467      <Button key={`hunt:${action.key}`} hotkey={action.hotkey} plain dimColor={action.dimColor === true} onPress={action.onPress}>
468        {action.label}
469      </Button>,
470    )
471  }
472
473  return <Box>{parts}</Box>
474}
475
476function wrapOf(text: string, width: number, maxLines: number): string[] {
477  const lines: string[] = []
478  let line = ''
479
480  for (const word of text.split(/\s+/).filter(Boolean)) {
481    const next = line ? `${line} ${word}` : word
482    if ([...next].length > width && line) {
483      lines.push(line)
484      line = word
485    } else {
486      line = next
487    }
488  }
489  if (line) {
490    lines.push(line)
491  }
492  if (lines.length <= maxLines) {
493    return lines
494  }
495
496  const kept = lines.slice(0, maxLines)
497  kept[maxLines - 1] = fitOf(lines.slice(maxLines - 1).join(' '), width).trimEnd()
498
499  return kept
500}
501
502function cellsOf(ui: ClientElements, cells: readonly Cell[]): RenderElement[] {
503  const { Text } = ui
504  const groups: Cell[] = []
505
506  for (const cell of cells) {
507    const last = groups[groups.length - 1]
508    if (last && last.color === cell.color && last.dim === cell.dim && last.bold === cell.bold) {
509      last.ch += cell.ch
510    } else {
511      groups.push({ ...cell })
512    }
513  }
514
515  return groups.map(group => (
516    <Text color={group.color} dimColor={group.dim} bold={group.bold}>{group.ch}</Text>
517  ))
518}
519
hooks/paint.ts 94 lines
1import type { Stage } from './hunt'
2
3export type Cell = { ch: string; color?: string; dim?: boolean; bold?: boolean }
4
5export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
6export const ACCENT = '#f5b942'
7export const SILENCE = '#e3c35a'
8export const STAGE_LOOK: Record<Stage, { glyph: string; color: string }> = {
9  applied: { glyph: '○', color: '#6ea8fe' },
10  screen: { glyph: '◐', color: '#4fd1c5' },
11  interview: { glyph: '●', color: '#c084fc' },
12  offer: { glyph: '★', color: '#4ade80' },
13  rejected: { glyph: '✗', color: '#f87171' },
14  closed: { glyph: '◌', color: '#7b8190' },
15}
16
17const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
18const MINUTE_MS = 60_000
19const HOUR_MS = 60 * MINUTE_MS
20const DAY_MS = 24 * HOUR_MS
21
22export function hexOf(hue: number, saturation: number, lightness: number): string {
23  const h = ((hue % 360) + 360) % 360
24  const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation
25  const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1))
26  const m = lightness - chroma / 2
27  const [r, g, b] =
28    h < 60 ? [chroma, x, 0]
29    : h < 120 ? [x, chroma, 0]
30    : h < 180 ? [0, chroma, x]
31    : h < 240 ? [0, x, chroma]
32    : h < 300 ? [x, 0, chroma]
33    : [chroma, 0, x]
34  const byte = (v: number) => Math.round(Math.min(1, Math.max(0, v + m)) * 255).toString(16).padStart(2, '0')
35
36  return `#${byte(r)}${byte(g)}${byte(b)}`
37}
38
39export function fitColorOf(fit: number): string {
40  return hexOf((Math.min(100, Math.max(0, fit)) / 100) * 130, 0.7, 0.55)
41}
42
43export function fitBarOf(fit: number, width: number): Cell[] {
44  const filled = (Math.min(100, Math.max(0, fit)) / 100) * width
45  const full = Math.floor(filled)
46  const partial = Math.floor((filled - full) * 8)
47  const color = fitColorOf(fit)
48  const cells: Cell[] = []
49
50  for (let i = 0; i < width; i++) {
51    if (i < full) {
52      cells.push({ ch: '█', color })
53    } else if (i === full && partial > 0) {
54      cells.push({ ch: EIGHTHS[partial] ?? ' ', color })
55    } else {
56      cells.push({ ch: '·', dim: true })
57    }
58  }
59
60  return cells
61}
62
63export function fitOf(text: string, width: number): string {
64  if (width <= 0) {
65    return ''
66  }
67  const chars = [...text]
68
69  return chars.length <= width ? text.padEnd(width) : `${chars.slice(0, width - 1).join('')}…`
70}
71
72export function agoOf(ms: number | null, now: number): string {
73  if (ms === null) {
74    return 'never'
75  }
76  const passed = Math.max(0, now - ms)
77
78  return passed < MINUTE_MS ? 'just now'
79    : passed < HOUR_MS ? `${Math.floor(passed / MINUTE_MS)}m ago`
80    : passed < DAY_MS ? `${Math.floor(passed / HOUR_MS)}h ago`
81    : `${Math.floor(passed / DAY_MS)}d ago`
82}
83
84export function minutesOf(ms: number): string {
85  return `${Math.max(0, Math.floor(ms / MINUTE_MS))} min`
86}
87
88export function dayOf(ms: number): string {
89  const date = new Date(ms)
90  const two = (v: number) => String(v).padStart(2, '0')
91
92  return `${two(date.getUTCDate())}.${two(date.getUTCMonth() + 1)}`
93}
94
types/index.d.ts 56 lines
1export type Stage = 'applied' | 'screen' | 'interview' | 'offer' | 'rejected' | 'closed'
2
3export type Step = { stage: Stage; on: string }
4
5export type Application = {
6  id: string
7  company: string
8  role: string
9  url: string | null
10  resume: string | null
11  stage: Stage
12  history: Step[]
13  note: string | null
14}
15
16export type Tracker = { v: 1; applications: Application[]; hidden: string[] }
17
18export type Lead = {
19  id: string
20  company: string
21  title: string
22  url: string
23  location: string
24  country: string
25  sponsor: boolean
26  remote: boolean
27  salary: string | null
28  posted: string | null
29  fit: number
30  why: string | null
31  found_ms: number
32}
33
34export type Leads = { v: 1; scanned_ms: number | null; query: string | null; leads: Lead[] }
35
36export type Profile = { name: string; title: string; place: string | null; skills: string[]; english: string | null }
37
38export type FileEntry = { dir: string; name: string; mtime_ms: number }
39
40export type Busy = { kind: 'init' | 'scan' | 'tailor'; label: string; since_ms: number }
41
42export type Pending = { busy: Busy; text: string; turnId: string | null; done: string | null }
43
44export type Folder = { root: string; profile: Profile | null; files: FileEntry[]; tracker: Tracker; leads: Leads }
45
46declare module 'claude-code' {
47  interface PluginState {
48    'job-hunt': {
49      folder: Folder
50      now: number
51      pending: Pending | null
52      isDemo: boolean
53    }
54  }
55}
56