@timurtekb/tekjobs 0.35.0 → 0.35.2

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.
@@ -8,7 +8,7 @@
8
8
  <link rel="icon" href="/brand/tekjobs-avatar-192.png" type="image/png" sizes="192x192" />
9
9
  <link rel="apple-touch-icon" href="/brand/tekjobs-avatar-192.png" />
10
10
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@500;600&family=Hanken+Grotesk:wght@400;500;600&family=Azeret+Mono:wght@400;500&display=swap" data-zengin="fonts" />
11
- <script type="module" crossorigin src="/assets/index-CgarU0W5.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-BTDWWylf.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-CR3ppGJT.css">
13
13
  </head>
14
14
  <body>
@@ -235,7 +235,9 @@ export function classifyRun({ code, out = '', err = '', command = 'claude' }) {
235
235
  }
236
236
  if (code === 0 && out.trim()) return null;
237
237
  const signedOut = /not logged in|please run \/login|run `?\/login|failed to authenticate|authentication[_ ]error|invalid[_ ]api[_ ]key|token (has )?expired|(^|\W)unauthorized(\W|$)|\b(http|status|error)\s*:?\s*401\b/i;
238
- if (signedOut.test(err) || (!out.trim() && signedOut.test(out))) {
238
+ // Claude Code prints "Not logged in · Please run /login" on stdout with a non-zero exit, so a failed run's
239
+ // stdout counts too; a successful run's stdout never does, whatever words it holds.
240
+ if (signedOut.test(err) || (code !== 0 && signedOut.test(out))) {
239
241
  return Object.assign(new Error(`${command} is signed out. Open a terminal, run "${command}", sign in, then try again. (The desktop app's login is separate from the terminal's.) It said: ${tail || 'nothing'}`), { kind: 'auth' });
240
242
  }
241
243
  if (/requires a newer version|please upgrade/i.test(`${out}\n${err}`)) {
@@ -255,7 +257,7 @@ export function start(id, opts = {}) {
255
257
  if (cur?.running) return state(id);
256
258
  store.getJob(id); // throws early, and synchronously, if there is no such job
257
259
  drafts.set(id, { running: true, startedAt: new Date().toISOString(), finishedAt: null, error: '', errorKind: '' });
258
- materials(id, opts).then((m) => runLLM(m.prompt))
260
+ materials(id, opts).then((m) => runLLM(m.prompt)).then((text) => { if (String(text).trim().length < 80) throw Object.assign(new Error(`The writing CLI answered with "${String(text).trim().slice(0, 140)}" instead of a letter. If that is a sign-in or permission message, fix it in a terminal and try again.`), { kind: 'failed' }); return text; })
259
261
  .then((text) => { save(id, text, `app · ${runnerConfig().command}`); drafts.set(id, { ...drafts.get(id), running: false, finishedAt: new Date().toISOString() }); })
260
262
  .catch((e) => drafts.set(id, { ...drafts.get(id), running: false, finishedAt: new Date().toISOString(), error: e.message, errorKind: e.kind || 'failed' }));
261
263
  return state(id);
@@ -10,6 +10,7 @@ import * as tailored from './tailored-resume.mjs';
10
10
  import * as mail from './mail-check.mjs';
11
11
  import * as people from './people.mjs';
12
12
  import * as linkedin from './linkedin-import.mjs';
13
+ import { TOOLS } from './mcp.mjs';
13
14
 
14
15
  const PORT = Number(process.env.PORT || 8787);
15
16
  const DIST = fileURLToPath(new URL('../dist', import.meta.url));
@@ -20,6 +21,8 @@ const readBody = (req) => new Promise((resolve, reject) => { let s = ''; req.on(
20
21
 
21
22
  const routes = [
22
23
  ['GET', /^\/api\/summary$/, () => store.summary()],
24
+ // The MCP tools the server offers, for the Agent access page; importing mcp.mjs does not start its stdio loop.
25
+ ['GET', /^\/api\/tools$/, () => TOOLS.map((t) => ({ name: t.name, description: t.description }))],
23
26
  ['GET', /^\/api\/today$/, (_, q) => store.today({ cap: Number(q.get('cap') || 7), waitingDays: Number(q.get('waitingDays') || 14) })],
24
27
  ['GET', /^\/api\/jobs$/, (_, q) => store.searchJobs({ ...store.filtersFromParams(q), sort: q.get('sort') || 'score', dir: q.get('dir') || 'desc', limit: Number(q.get('limit') || 500), offset: Number(q.get('offset') || 0) })],
25
28
  ['GET', /^\/api\/jobs\/facets$/, (_, q) => store.jobFacets(store.filtersFromParams(q))],
@@ -50,7 +50,7 @@ Report every message that is about one of the person's job applications: confirm
50
50
  The person's own records show applications at these companies (others may exist): ${companies.join(', ')}.` : ''}
51
51
 
52
52
  Output ONLY a JSON array, no prose, no code fence, one object per message:
53
- {"company": "company name as the email gives it", "role": "role title if stated, else empty string", "kind": "confirmation|rejection|advance|scheduling|info-request|outreach|other", "date": "YYYY-MM-DD", "gist": "one plain sentence", "from": "the sender exactly as the header shows it, Display Name <address>", "fromName": "the human who wrote, from the sender display name or the signature; empty only when no human is named. For a LinkedIn relay address (inmail-hit-reply@linkedin.com, hit-reply@linkedin.com) the display name is the person, so read the message if the search result does not show it; required for outreach", "postingUrl": "a link in the email to the job posting or to the application itself, if there is one, else empty string", "reqId": "a requisition or job id the email quotes (R-12345, JR361297, Job ID 10502343), else empty string", "messageId": "gmail message id", "subject": "subject line"}
53
+ {"company": "company name as the email gives it", "role": "role title if stated, else empty string", "kind": "confirmation|rejection|advance|scheduling|info-request|outreach|other", "date": "YYYY-MM-DD", "gist": "one plain sentence", "from": "the sender exactly as the header shows it, Display Name <address>", "fromName": "the human who wrote, from the sender display name or the signature; empty only when no human is named. For a LinkedIn relay address (inmail-hit-reply@linkedin.com, hit-reply@linkedin.com) the display name is the person, so read the message if the search result does not show it; required for outreach", "postingUrl": "a link in the email to the job posting or to the application itself, if there is one, else empty string; never a scheduling, meeting, calendar or unsubscribe link", "reqId": "a requisition or job id the email quotes (R-12345, JR361297, Job ID 10502343), else empty string", "messageId": "gmail message id", "subject": "subject line"}
54
54
 
55
55
  If nothing matches, output [].`;
56
56
  }
@@ -65,7 +65,7 @@ export function parseOutput(text) {
65
65
  return arr.filter((m) => m && typeof m === 'object' && m.company && m.messageId).map((m) => ({
66
66
  company: String(m.company).trim(), role: String(m.role || '').trim(), kind: KINDS.includes(m.kind) ? m.kind : 'other',
67
67
  date: /^\d{4}-\d{2}-\d{2}$/.test(String(m.date || '')) ? m.date : '', gist: String(m.gist || '').trim().slice(0, 300),
68
- postingUrl: /^https?:\/\//i.test(String(m.postingUrl || '').trim()) ? String(m.postingUrl).trim().slice(0, 500) : '', reqId: String(m.reqId || '').trim().slice(0, 60),
68
+ postingUrl: /^https?:\/\//i.test(String(m.postingUrl || '').trim()) && !/calendly|meeting|schedul|zoom\.us|goodtime|modernloop|unsubscribe|list-manage/i.test(String(m.postingUrl)) ? String(m.postingUrl).trim().slice(0, 500) : '', reqId: String(m.reqId || '').trim().slice(0, 60),
69
69
  from: String(m.from || '').trim(), fromName: String(m.fromName || '').trim().slice(0, 80), messageId: String(m.messageId).trim(), subject: String(m.subject || '').trim().slice(0, 200),
70
70
  }));
71
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurtekb/tekjobs",
3
- "version": "0.35.0",
3
+ "version": "0.35.2",
4
4
  "description": "A local-first job-search machine: watches hundreds of company job boards, scores every posting against your profile, and files matches into a folder of markdown notes you own. Bring your own LLM through MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,7 +54,7 @@ export function onboardingStatus(dir = VAULT) {
54
54
  const steps = [
55
55
  { id: 'folder', label: 'Profile folder exists', done: exists('Targets/Search Criteria.md') && exists('Targets/Companies.md'), how: 'tekjobs init [dir]' },
56
56
  { id: 'resume', label: 'Resume imported', done: exists('Profile/Resume - Source.md') || (exists('Profile') && fs.readdirSync(path.join(dir, 'Profile')).some((f) => /^Resume.*\.md$/i.test(f))), how: 'tekjobs resume <file.pdf|docx|md>' },
57
- { id: 'profile', label: 'Profile written by the interview', done: profileFilled, how: 'Run the interview: in Claude Code (or any MCP client connected to tekjobs), ask it to call onboarding_materials and interview you.' },
57
+ { id: 'profile', label: 'Profile written by the interview', done: profileFilled, how: 'Run the interview. Connect the server once (Claude Code: claude mcp add tekjobs -- tekjobs mcp; other clients: the command tekjobs mcp), then say: Use the tekjobs MCP server, call onboarding_status, then onboarding_materials, and follow its script.' },
58
58
  { id: 'criteria', label: 'Search criteria filled (title terms set)', done: criteriaFilled, how: 'The interview writes them; or edit Targets/Search Criteria.md.' },
59
59
  { id: 'scan', label: 'First scan has run', done: jobs > 0, how: 'tekjobs scan, or the Runs screen.' },
60
60
  ];