trackly-cli 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,7 @@ trackly jobs --function product
35
35
 
36
36
  ## At a Glance
37
37
 
38
- 1,900+ companies | 128K+ jobs | 40+ ATS types | CLI + MCP | 20 local MCP tools
38
+ 1,900+ companies | 128K+ jobs | 40+ ATS types | CLI + MCP | 21 local MCP tools
39
39
 
40
40
  ## CLI Commands
41
41
 
@@ -44,6 +44,7 @@ trackly jobs # List jobs
44
44
  trackly jobs --remote # Filter remote jobs (sets usStates=REMOTE)
45
45
  trackly jobs --region us # Filter by region (us, non_us, all, or a region tag: europe, canada, remote, ...)
46
46
  trackly jobs --job-type internship # Filter by employment type (full_time, internship, all)
47
+ trackly jobs --work-arrangement hybrid,remote # Filter by work arrangement independently
47
48
  trackly jobs --function product # Filter by function
48
49
  trackly jobs --company 243 # Filter by company ID
49
50
  trackly job 1234 # Get job details
@@ -152,7 +153,7 @@ Then use natural language in any of these clients:
152
153
 
153
154
  | Tool | Description |
154
155
  |------|-------------|
155
- | trackly_search_jobs | Search and filter jobs by function, company, location, modality, status |
156
+ | trackly_search_jobs | Search and filter jobs by function, company, region, employment type, work arrangement, and status |
156
157
  | trackly_get_job | Get full details for a specific job |
157
158
  | trackly_search_companies | Semantic company search |
158
159
  | trackly_list_companies | List all tracked companies |
@@ -172,6 +173,7 @@ Then use natural language in any of these clients:
172
173
  | trackly_report_apply_observation | Send redacted ATS mechanics feedback |
173
174
  | trackly_record_application_outcome | Record review or confirmed submission outcome |
174
175
  | trackly_prepare_resume | Prepare a private expiring resume file for upload |
176
+ | trackly_verify_prepared_resume | Recheck the confirmed resume immediately before attachment |
175
177
 
176
178
  ## Authentication
177
179
 
@@ -245,7 +247,7 @@ Install trackly-cli (`npm install -g trackly-cli`), authenticate with `trackly l
245
247
 
246
248
  **What MCP servers exist for job searching?**
247
249
 
248
- trackly-cli includes a built-in MCP server with 20 tools for job search, company lookup, application tracking, profile onboarding, and manual-submit form preparation. Run `trackly mcp` or use `trackly agent setup --client claude`.
250
+ trackly-cli includes a built-in MCP server with 21 tools for job search, company lookup, application tracking, profile onboarding, and manual-submit form preparation. Run `trackly mcp` or use `trackly agent setup --client claude`.
249
251
 
250
252
  **How do I use Claude Code for job hunting?**
251
253
 
package/bin/trackly CHANGED
@@ -57,7 +57,7 @@ const COMMAND_FLAGS = {
57
57
  login: [],
58
58
  logout: [],
59
59
  whoami: [],
60
- jobs: ['function', 'company', 'region', 'job-type', 'remote', 'status', 'sort', 'limit', 'offset', 'keywords', 'location', 'modality'],
60
+ jobs: ['function', 'company', 'region', 'job-type', 'work-arrangement', 'remote', 'status', 'sort', 'limit', 'offset', 'keywords', 'location', 'modality'],
61
61
  job: [],
62
62
  companies: ['limit', 'offset'],
63
63
  search: [],
@@ -86,6 +86,7 @@ const STATUS_LABELS = {
86
86
  saved: 'saved',
87
87
  dismissed: 'dismissed',
88
88
  };
89
+ const WORK_ARRANGEMENT_VALUES = ['remote', 'hybrid', 'in_person', 'unspecified'];
89
90
 
90
91
  function parseArgs(args) {
91
92
  const result = { _: [], command: null };
@@ -262,7 +263,7 @@ function hasJobListFilters(args) {
262
263
  // (which then raises the migration error) instead of cmdJob(42) silently ignoring
263
264
  // the filter. See PR #13 Cursor Bugbot finding.
264
265
  const deprecated = Object.keys(DEPRECATED_JOB_FLAGS);
265
- const active = ['function', 'company', 'keywords', 'limit', 'region', 'job-type', 'remote', 'offset', 'sort', 'status'];
266
+ const active = ['function', 'company', 'keywords', 'limit', 'region', 'job-type', 'work-arrangement', 'remote', 'offset', 'sort', 'status'];
266
267
  return [...active, ...deprecated].some((key) => args[key] !== undefined);
267
268
  }
268
269
 
@@ -506,11 +507,24 @@ async function cmdJobs(args) {
506
507
  failNow('--remote cannot be combined with --region. Use one or the other (e.g. --region remote).');
507
508
  }
508
509
 
510
+ let workArrangements;
511
+ if (args['work-arrangement'] !== undefined) {
512
+ if (typeof args['work-arrangement'] !== 'string' || !args['work-arrangement'].trim()) {
513
+ failNow('--work-arrangement requires a comma-separated value: remote, hybrid, in_person, unspecified.');
514
+ }
515
+ workArrangements = [...new Set(args['work-arrangement'].split(',').map((value) => value.trim()))];
516
+ const invalid = workArrangements.filter((value) => !WORK_ARRANGEMENT_VALUES.includes(value));
517
+ if (invalid.length > 0) {
518
+ failNow(`Invalid --work-arrangement value "${invalid.join(',')}". Use: ${WORK_ARRANGEMENT_VALUES.join(', ')}.`);
519
+ }
520
+ }
521
+
509
522
  const params = new URLSearchParams();
510
523
  if (args.function) params.set('jobFunction', args.function);
511
524
  if (args.company) params.set('companyId', args.company);
512
525
  if (args.region) params.set('locationFilter', args.region);
513
526
  if (args['job-type']) params.set('jobModality', args['job-type']);
527
+ if (workArrangements) params.set('workArrangements', workArrangements.join(','));
514
528
  if (args.remote) params.set('usStates', 'REMOTE');
515
529
  if (args.status) params.set('status', args.status);
516
530
  if (args.sort) params.set('sort', args.sort);
@@ -994,7 +1008,7 @@ ${color('bold', 'Commands:')}
994
1008
  ${color('cyan', 'login')} Authenticate via Google OAuth
995
1009
  ${color('cyan', 'logout')} Clear credentials
996
1010
  ${color('cyan', 'whoami')} Show current user
997
- ${color('cyan', 'jobs')} List jobs (--function, --company <id>, --region <tag>, --job-type <type>, --remote, --status, --sort, --limit, --offset, --keywords)
1011
+ ${color('cyan', 'jobs')} List jobs (--function, --company <id>, --region <tag>, --job-type <type>, --work-arrangement <values>, --remote, --status, --sort, --limit, --offset, --keywords)
998
1012
  ${color('cyan', 'job <id>')} Get full job detail
999
1013
  ${color('cyan', 'jobs <id>')} Alias for job <id>
1000
1014
  ${color('cyan', 'companies')} List companies
@@ -1036,8 +1050,10 @@ ${color('bold', 'Filter values:')}
1036
1050
  Use a comma-separated list of region tags for multiple regions;
1037
1051
  us/non_us/all cannot be combined.
1038
1052
  --job-type full_time, internship, all (employment type, not work-location)
1053
+ --work-arrangement
1054
+ remote, hybrid, in_person, unspecified (comma-separated)
1039
1055
  --remote Flag (no value). Filters for remote jobs (equivalent to usStates=REMOTE).
1040
- --status new, applying, applied_confirmed, check_later, not_interested, all
1056
+ --status new, applied_confirmed, check_later, not_interested, all
1041
1057
  (filters by YOUR application pipeline state)
1042
1058
  --sort newest (default), match (highest match score first, requires resume)
1043
1059
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": "1.0.0",
2
+ "contractVersion": "2.0.0",
3
3
  "tools": {
4
4
  "trackly_get_apply_queue": "{limit:z.number().int().min(1).max(100).optional()}",
5
5
  "trackly_get_application_profile": "{includeSensitive:z.boolean().optional(),provider:z.string().max(100).optional(),companyId:z.string().max(100).optional()}",
@@ -9,6 +9,10 @@
9
9
  "trackly_get_apply_protocol": "{}",
10
10
  "trackly_report_apply_observation": "{runId:z.number().int().min(1).optional(),provider:z.string().min(1).max(100),fieldLabel:z.string().min(1).max(1000),observationType:z.string().min(1).max(100),resolutionCode:z.string().max(100).optional(),metadata:z.object({controlType:z.string().max(100).optional(),required:z.boolean().optional(),errorCode:z.string().max(100).optional(),committed:z.boolean().optional()}).optional()}",
11
11
  "trackly_record_application_outcome": "{runId:z.number().int().min(1),outcome:z.enum(['review_ready','submitted','failed','blocked']),confirmation:z.string().max(500).optional()}",
12
- "trackly_prepare_resume": "{}"
12
+ "trackly_prepare_resume": "{runId:z.number().int().min(1)}",
13
+ "trackly_verify_prepared_resume": {
14
+ "local": "{runId:z.number().int().min(1),confirmationId:z.string().min(1).max(200),exactLocalPath:z.string().min(1).max(4096),sha256:z.string().regex(/^[a-f0-9]{64}$/i),sizeBytes:z.number().int().min(1),expiresAt:z.string().datetime()}",
15
+ "hosted": "{runId:z.number().int().min(1),confirmationId:z.string().min(1).max(200)}"
16
+ }
13
17
  }
14
18
  }
@@ -72,9 +72,10 @@ You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
72
72
  - `locationFilter`: pass ONE of
73
73
  - a single scalar: `us`, `non_us`, `all`, or a region tag (`europe`, `latam`, `middle_east`, `asia`, `africa`, `canada`, `oceania`, `remote`, `unknown`), OR
74
74
  - an array of region tags for multi-region (e.g. `["europe", "canada"]`). The array form excludes `us` — combining `us` with other tags in an array causes the backend to silently drop the others. To get "not US" use the scalar `non_us` alone.
75
- - `jobModality`: `full_time`, `internship`, `all` — employment type, NOT work-location style. For remote filtering, use the `remote` boolean or `locationFilter: "remote"`. Hybrid and onsite are not exposed as filters.
75
+ - `jobModality`: `full_time`, `internship`, `all` — employment type, NOT work arrangement.
76
+ - `workArrangements`: one or more of `remote`, `hybrid`, `in_person`, `unspecified`. This is an independent axis and combines with region, employment type, and function filters.
76
77
  - `remote` (boolean): filter to remote jobs only. Maps to `usStates=REMOTE`.
77
- - `status`: your application pipeline state. Values: `new`, `applied_confirmed`, `check_later`, `not_interested`, `all`. Older `applying` requests are accepted by the backend as a private compatibility alias for `check_later`, but new clients must not emit it.
78
+ - `status`: your application pipeline state. Values: `new`, `applied_confirmed`, `check_later`, `not_interested`, `all`.
78
79
  - `sort`: `newest` (default) or `match` (highest match score first; requires a resume on file). Backend rejects the deprecated values `oldest` and `company` with HTTP 400.
79
80
  - `keywords`, `companyId`, `limit`, `offset`.
80
81
  - **trackly_get_job** — Get full job details by ID
@@ -95,7 +96,10 @@ You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
95
96
  - **trackly_get_apply_protocol** — Get the current ATS support, browser integrity rules, and compatible skill version.
96
97
  - **trackly_report_apply_observation** — Report redacted ATS mechanics without answer values.
97
98
  - **trackly_record_application_outcome** — Record review readiness or a confirmed manual submission.
98
- - **trackly_prepare_resume** — Local MCP only: materialize the default resume in a private, expiring mode-0600 cache. Hosted MCP returns a manual/local-agent requirement.
99
+ - **trackly_prepare_resume** — Local MCP only: materialize the default resume in a private, expiring mode-0600 cache and return filename, size, SHA-256, exact local path, and visual-confirmation metadata. Hosted MCP returns a manual/local-agent requirement.
100
+ - **trackly_verify_prepared_resume** — Local MCP only: immediately before attachment, recompute the user-confirmed resume hash and size, validate the exact path/run/expiration, and lock the file read-only. Any mismatch requires a fresh preview and confirmation.
101
+
102
+ Apply contract v2 intentionally gives this verifier different local and hosted schemas: local MCP receives the full proof needed to inspect the private file, while hosted MCP accepts only run and confirmation identifiers and returns the manual/local-agent requirement. Local paths and fingerprints are never sent to the hosted verifier.
99
103
 
100
104
  ### Maintenance behavior
101
105
 
package/lib/agent.js CHANGED
@@ -7,7 +7,7 @@ const path = require('node:path');
7
7
  const { spawnSync } = require('node:child_process');
8
8
  const { apiRequest, downloadFile, getConfigPaths, hasAuth, maintenanceOutput } = require('./client');
9
9
  const { version: PACKAGE_VERSION } = require('../package.json');
10
- const SKILL_VERSION = '1.0.1';
10
+ const SKILL_VERSION = '2.0.0';
11
11
  const CLI_USER_AGENT = `trackly-cli/${PACKAGE_VERSION}`;
12
12
  const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
13
13
 
@@ -15,6 +15,8 @@ const SKILL_NAME = 'trackly-apply';
15
15
  const SKILL_MAJOR = Number(SKILL_VERSION.split('.')[0]);
16
16
  const CACHE_TTL_MS = 2 * 60 * 60 * 1000;
17
17
  const MANAGED_FILE = '.trackly-managed.json';
18
+ const RESUME_PROOF_FILE = '.trackly-resume-proof.json';
19
+ const RESUME_PROOF_KEY_BYTES = 32;
18
20
 
19
21
  function bundledSkillDir() {
20
22
  return path.join(__dirname, '..', 'skills', SKILL_NAME);
@@ -292,26 +294,288 @@ function cleanResumeCache(now = Date.now()) {
292
294
  const dir = cacheDir();
293
295
  if (!fs.existsSync(dir)) return { removed: 0, directory: dir };
294
296
  let removed = 0;
295
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
296
- if (!entry.isFile()) continue;
297
- const file = path.join(dir, entry.name);
298
- const stat = fs.statSync(file);
299
- if (now - stat.mtimeMs > CACHE_TTL_MS) {
300
- fs.rmSync(file, { force: true });
301
- removed++;
297
+
298
+ function ignoreConcurrentRemoval(error, codes = ['ENOENT']) {
299
+ if (codes.includes(error?.code)) return true;
300
+ throw error;
301
+ }
302
+
303
+ function cleanDirectory(current, removeWhenExpired = false) {
304
+ let directoryStat;
305
+ try {
306
+ directoryStat = fs.lstatSync(current);
307
+ } catch (error) {
308
+ if (ignoreConcurrentRemoval(error)) return;
309
+ }
310
+ if (!directoryStat.isDirectory()) return;
311
+ const directoryExpired = removeWhenExpired && now - directoryStat.mtimeMs > CACHE_TTL_MS;
312
+ let entries;
313
+ try {
314
+ entries = fs.readdirSync(current, { withFileTypes: true });
315
+ } catch (error) {
316
+ if (ignoreConcurrentRemoval(error)) return;
317
+ }
318
+ for (const entry of entries) {
319
+ const item = path.join(current, entry.name);
320
+ if (entry.isDirectory()) {
321
+ cleanDirectory(item, true);
322
+ continue;
323
+ }
324
+ if (!entry.isFile()) continue;
325
+ let stat;
326
+ try {
327
+ stat = fs.lstatSync(item);
328
+ } catch (error) {
329
+ if (ignoreConcurrentRemoval(error)) continue;
330
+ }
331
+ if (!stat.isFile()) continue;
332
+ if (now - stat.mtimeMs > CACHE_TTL_MS) {
333
+ try {
334
+ fs.rmSync(item, { force: true });
335
+ removed++;
336
+ } catch (error) {
337
+ if (!ignoreConcurrentRemoval(error)) throw error;
338
+ }
339
+ }
340
+ }
341
+ if (!directoryExpired) return;
342
+ try {
343
+ if (fs.readdirSync(current).length === 0) fs.rmdirSync(current);
344
+ } catch (error) {
345
+ ignoreConcurrentRemoval(error, ['ENOENT', 'ENOTEMPTY']);
302
346
  }
303
347
  }
348
+
349
+ cleanDirectory(dir);
304
350
  return { removed, directory: dir };
305
351
  }
306
352
 
307
353
  function safeResumeName(fileName, contentType) {
308
354
  const extension = contentType === 'application/pdf' ? '.pdf' : '.docx';
309
- const base = path.basename(fileName || `resume${extension}`).replace(/[^A-Za-z0-9._ -]/g, '_').slice(0, 120);
310
- return base.toLowerCase().endsWith(extension) ? base : `${base}${extension}`;
355
+ const value = fileName == null || fileName === '' ? `resume${extension}` : String(fileName);
356
+ const invalid = value === '.'
357
+ || value === '..'
358
+ || value !== value.trim()
359
+ || /[\\/\0-\x1f\x7f]/.test(value)
360
+ || Buffer.byteLength(value, 'utf8') > 240
361
+ || !value.toLowerCase().endsWith(extension);
362
+ if (invalid) throw new Error('Trackly returned an unsafe or incompatible resume filename. Update the default resume and try again.');
363
+ return value;
311
364
  }
312
365
 
313
366
  function resumeCacheName(fileName, contentType, now = Date.now(), nonce = crypto.randomBytes(4).toString('hex')) {
314
- return `${now}-${nonce}-${safeResumeName(fileName, contentType)}`;
367
+ return path.join(`${now}-${nonce}`, safeResumeName(fileName, contentType));
368
+ }
369
+
370
+ function resumeConfirmation(fileName, sha256, sizeBytes, exactLocalPath, runId, expiresAt, confirmationId) {
371
+ return {
372
+ required: true,
373
+ source: 'trackly_default_resume',
374
+ runId,
375
+ confirmationId,
376
+ fileName,
377
+ sha256,
378
+ sizeBytes,
379
+ expiresAt,
380
+ verification: {
381
+ preferred: 'local_preview',
382
+ exactLocalPath,
383
+ displayExactPathRequired: true,
384
+ },
385
+ };
386
+ }
387
+
388
+ function resumeProofKey() {
389
+ const configDir = path.resolve(getConfigPaths().dir);
390
+ ensureDir(configDir, 0o700);
391
+ const keyPath = path.join(configDir, 'resume-proof.key');
392
+
393
+ function readExistingKey() {
394
+ const stat = fs.lstatSync(keyPath);
395
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) {
396
+ throw new Error('The Trackly resume-proof key is not a private regular file.');
397
+ }
398
+ const key = fs.readFileSync(keyPath);
399
+ if (key.length !== RESUME_PROOF_KEY_BYTES) throw new Error('The Trackly resume-proof key is invalid.');
400
+ return key;
401
+ }
402
+
403
+ try {
404
+ return readExistingKey();
405
+ } catch (error) {
406
+ if (error?.code !== 'ENOENT') throw error;
407
+ }
408
+
409
+ const candidate = crypto.randomBytes(RESUME_PROOF_KEY_BYTES);
410
+ try {
411
+ fs.writeFileSync(keyPath, candidate, { mode: 0o600, flag: 'wx' });
412
+ fs.chmodSync(keyPath, 0o600);
413
+ return candidate;
414
+ } catch (error) {
415
+ candidate.fill(0);
416
+ if (error?.code !== 'EEXIST') throw error;
417
+ return readExistingKey();
418
+ }
419
+ }
420
+
421
+ function signedResumeProof(record) {
422
+ const payload = JSON.stringify(record);
423
+ const key = resumeProofKey();
424
+ try {
425
+ return {
426
+ ...record,
427
+ signature: crypto.createHmac('sha256', key).update(payload).digest('hex'),
428
+ };
429
+ } finally {
430
+ key.fill(0);
431
+ }
432
+ }
433
+
434
+ function verifyResumeProofSignature(manifest) {
435
+ const { signature, ...record } = manifest || {};
436
+ if (!/^[a-f0-9]{64}$/i.test(signature || '')) throw new Error('The prepared resume proof is invalid. Prepare and confirm it again.');
437
+ const key = resumeProofKey();
438
+ try {
439
+ const expected = crypto.createHmac('sha256', key).update(JSON.stringify(record)).digest();
440
+ if (!crypto.timingSafeEqual(expected, Buffer.from(signature, 'hex'))) {
441
+ throw new Error('The prepared resume proof is invalid. Prepare and confirm it again.');
442
+ }
443
+ } finally {
444
+ key.fill(0);
445
+ }
446
+ return record;
447
+ }
448
+
449
+ function materializeResume(download, options = {}) {
450
+ const runId = Number(options.runId);
451
+ if (!Number.isInteger(runId) || runId < 1) throw new Error('A valid Trackly Apply run ID is required before preparing a resume.');
452
+ const now = options.now ?? Date.now();
453
+ const nonce = options.nonce ?? crypto.randomBytes(4).toString('hex');
454
+ const confirmationId = options.confirmationId ?? crypto.randomUUID();
455
+ const validated = validateResumeFile(download);
456
+ const dir = cacheDir();
457
+ ensureDir(dir, 0o700);
458
+ const name = resumeCacheName(download.fileName, validated.contentType, now, nonce);
459
+ const destination = path.join(dir, name);
460
+ ensureDir(path.dirname(destination), 0o700);
461
+ fs.writeFileSync(destination, download.buffer, { mode: 0o600, flag: 'wx' });
462
+ try { fs.chmodSync(destination, 0o600); } catch (_) {}
463
+ const fileName = path.basename(destination);
464
+ const expiresAt = new Date(now + CACHE_TTL_MS).toISOString();
465
+ const confirmation = resumeConfirmation(
466
+ fileName,
467
+ validated.digest,
468
+ download.buffer.length,
469
+ destination,
470
+ runId,
471
+ expiresAt,
472
+ confirmationId,
473
+ );
474
+ const manifestPath = path.join(path.dirname(destination), RESUME_PROOF_FILE);
475
+ try {
476
+ const manifest = signedResumeProof({
477
+ runId,
478
+ confirmationId,
479
+ exactLocalPath: destination,
480
+ fileName,
481
+ sha256: validated.digest,
482
+ sizeBytes: download.buffer.length,
483
+ expiresAt,
484
+ });
485
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest), { mode: 0o400, flag: 'wx' });
486
+ fs.chmodSync(manifestPath, 0o400);
487
+ } catch (error) {
488
+ fs.rmSync(destination, { force: true });
489
+ try { fs.rmdirSync(path.dirname(destination)); } catch (cleanupError) {
490
+ if (!['ENOENT', 'ENOTEMPTY'].includes(cleanupError?.code)) throw cleanupError;
491
+ }
492
+ throw error;
493
+ }
494
+ return {
495
+ path: destination,
496
+ fileName,
497
+ contentType: validated.contentType,
498
+ sha256: validated.digest,
499
+ sizeBytes: download.buffer.length,
500
+ expiresAt,
501
+ confirmation,
502
+ };
503
+ }
504
+
505
+ function verifyPreparedResume(proof, now = Date.now()) {
506
+ const runId = Number(proof?.runId);
507
+ if (!Number.isInteger(runId) || runId < 1) throw new Error('A valid Trackly Apply run ID is required to verify a prepared resume.');
508
+ if (!proof?.confirmationId || typeof proof.confirmationId !== 'string') throw new Error('The prepared resume confirmation ID is required.');
509
+ if (!/^[a-f0-9]{64}$/i.test(proof?.sha256 || '')) throw new Error('The prepared resume SHA-256 is invalid.');
510
+ if (!Number.isInteger(proof?.sizeBytes) || proof.sizeBytes < 1) throw new Error('The prepared resume size is invalid.');
511
+ const expiresAtMs = Date.parse(proof?.expiresAt || '');
512
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) throw new Error('The prepared resume confirmation expired. Prepare and confirm the resume again.');
513
+
514
+ const expectedPath = path.resolve(String(proof?.exactLocalPath || ''));
515
+ const cacheRoot = path.resolve(cacheDir());
516
+ if (!expectedPath.startsWith(`${cacheRoot}${path.sep}`)) throw new Error('The prepared resume path is outside the private Trackly cache.');
517
+
518
+ let realPath;
519
+ try {
520
+ realPath = fs.realpathSync(expectedPath);
521
+ } catch (error) {
522
+ if (error?.code === 'ENOENT') throw new Error('The prepared resume no longer exists. Prepare and confirm the resume again.');
523
+ throw error;
524
+ }
525
+ const realCacheRoot = fs.realpathSync(cacheRoot);
526
+ if (!realPath.startsWith(`${realCacheRoot}${path.sep}`)) throw new Error('The prepared resume resolves outside the private Trackly cache.');
527
+
528
+ let manifest;
529
+ try {
530
+ const manifestPath = path.join(path.dirname(expectedPath), RESUME_PROOF_FILE);
531
+ const manifestStat = fs.lstatSync(manifestPath);
532
+ if (!manifestStat.isFile() || manifestStat.isSymbolicLink() || (manifestStat.mode & 0o077) !== 0) {
533
+ throw new Error('The prepared resume proof is not private. Prepare and confirm it again.');
534
+ }
535
+ manifest = verifyResumeProofSignature(JSON.parse(fs.readFileSync(manifestPath, 'utf8')));
536
+ } catch (error) {
537
+ if (error instanceof SyntaxError || error?.code === 'ENOENT') {
538
+ throw new Error('The prepared resume proof is missing or invalid. Prepare and confirm it again.');
539
+ }
540
+ throw error;
541
+ }
542
+
543
+ const bindingMatches = manifest.runId === runId
544
+ && manifest.confirmationId === proof.confirmationId
545
+ && manifest.exactLocalPath === expectedPath
546
+ && manifest.fileName === path.basename(expectedPath)
547
+ && manifest.sha256 === proof.sha256
548
+ && manifest.sizeBytes === proof.sizeBytes
549
+ && manifest.expiresAt === proof.expiresAt;
550
+ if (!bindingMatches) throw new Error('The prepared resume proof does not match the confirmed run and file. Prepare and confirm it again.');
551
+
552
+ let stat;
553
+ try {
554
+ stat = fs.lstatSync(expectedPath);
555
+ } catch (error) {
556
+ if (error?.code === 'ENOENT') throw new Error('The prepared resume no longer exists. Prepare and confirm the resume again.');
557
+ throw error;
558
+ }
559
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('The prepared resume is not a regular private cache file.');
560
+ if ((stat.mode & 0o077) !== 0) throw new Error('The prepared resume permissions are not private.');
561
+ if (stat.size !== proof.sizeBytes) throw new Error('The prepared resume size changed after confirmation. Prepare and confirm it again.');
562
+
563
+ const sha256 = crypto.createHash('sha256').update(fs.readFileSync(expectedPath)).digest('hex');
564
+ if (!crypto.timingSafeEqual(Buffer.from(sha256, 'hex'), Buffer.from(proof.sha256, 'hex'))) {
565
+ throw new Error('The prepared resume contents changed after confirmation. Prepare and confirm it again.');
566
+ }
567
+
568
+ fs.chmodSync(expectedPath, 0o400);
569
+ return {
570
+ verified: true,
571
+ runId,
572
+ confirmationId: proof.confirmationId,
573
+ exactLocalPath: expectedPath,
574
+ sha256,
575
+ sizeBytes: stat.size,
576
+ expiresAt: new Date(expiresAtMs).toISOString(),
577
+ permissions: '400',
578
+ };
315
579
  }
316
580
 
317
581
  function validateResumeFile(download) {
@@ -333,25 +597,22 @@ function validateResumeFile(download) {
333
597
  };
334
598
  }
335
599
 
336
- async function prepareResume(userAgent = MCP_USER_AGENT) {
600
+ async function prepareResume(runId, userAgent = MCP_USER_AGENT) {
337
601
  if (!hasAuth()) throw new Error('Authenticate first with `trackly login` or `trackly config --api-key ...`.');
602
+ const normalizedRunId = Number(runId);
603
+ if (!Number.isInteger(normalizedRunId) || normalizedRunId < 1) {
604
+ throw new Error('A valid Trackly Apply run ID is required before preparing a resume.');
605
+ }
338
606
  cleanResumeCache();
339
- const download = await downloadFile('/api/jobscout/application-profile/default-resume', userAgent);
607
+ const download = await downloadFile(
608
+ `/api/jobscout/application-profile/default-resume?runId=${normalizedRunId}`,
609
+ userAgent,
610
+ );
340
611
  try {
341
- const validated = validateResumeFile(download);
342
- const dir = cacheDir();
343
- ensureDir(dir, 0o700);
344
- const name = resumeCacheName(download.fileName, validated.contentType);
345
- const destination = path.join(dir, name);
346
- fs.writeFileSync(destination, download.buffer, { mode: 0o600, flag: 'wx' });
347
- try { fs.chmodSync(destination, 0o600); } catch (_) {}
348
- return {
349
- path: destination,
350
- fileName: path.basename(destination),
351
- contentType: validated.contentType,
352
- sha256: validated.digest,
353
- expiresAt: new Date(Date.now() + CACHE_TTL_MS).toISOString(),
354
- };
612
+ if (Number(download.applyRunId) !== normalizedRunId) {
613
+ throw new Error('Trackly did not confirm that the resume belongs to the active Apply run.');
614
+ }
615
+ return materializeResume(download, { runId: normalizedRunId });
355
616
  } finally {
356
617
  download.buffer.fill(0);
357
618
  }
@@ -372,7 +633,7 @@ async function doctorAgent() {
372
633
  cache,
373
634
  protocol: null,
374
635
  profile: null,
375
- resume: { verified: false },
636
+ resume: { available: false },
376
637
  compatible: false,
377
638
  ok: false,
378
639
  };
@@ -390,24 +651,11 @@ async function doctorAgent() {
390
651
  };
391
652
  report.compatible = Number(report.protocol.compatibleSkillMajor) === SKILL_MAJOR;
392
653
  if (report.profile.hasDefaultResume) {
393
- try {
394
- const prepared = await prepareResume(CLI_USER_AGENT);
395
- const mode = fs.statSync(prepared.path).mode & 0o777;
396
- report.resume = {
397
- verified: mode === 0o600,
398
- contentType: prepared.contentType,
399
- permissions: mode.toString(8).padStart(3, '0'),
400
- expiresAt: prepared.expiresAt,
401
- };
402
- if (!report.resume.verified) report.resume.error = 'Prepared resume cache file is not mode 0600.';
403
- } catch (error) {
404
- const maintenance = maintenanceOutput(error);
405
- report.resume = {
406
- verified: false,
407
- error: error.error || error.message || String(error),
408
- ...(maintenance ? { maintenance } : {}),
409
- };
410
- }
654
+ report.resume = {
655
+ available: true,
656
+ fileName: profile.profile.defaultResume.fileName || null,
657
+ validation: 'Exact bytes are prepared and verified only after a real Apply run starts.',
658
+ };
411
659
  }
412
660
  } catch (error) {
413
661
  report.apiError = maintenanceOutput(error) || error.error || error.message || String(error);
@@ -416,14 +664,16 @@ async function doctorAgent() {
416
664
  && report.compatible
417
665
  && report.clients.some((client) => client.executable && client.installed && client.mcpRegistered)
418
666
  && Boolean(report.profile?.hasDefaultResume)
419
- && report.resume.verified
667
+ && report.resume.available
420
668
  && report.profile?.completeness?.missingKeys?.length === 0;
421
669
  return report;
422
670
  }
423
671
 
424
672
  function resumeValidationStatus(report) {
425
673
  if (!report?.profile?.hasDefaultResume) return 'not applicable (no default resume set)';
426
- return report.resume?.verified ? 'passed' : 'failed';
674
+ return report.resume?.available
675
+ ? 'available (exact bytes are verified during an active Apply run)'
676
+ : 'failed';
427
677
  }
428
678
 
429
679
  module.exports = {
@@ -439,9 +689,12 @@ module.exports = {
439
689
  clientSkillDir,
440
690
  doctorAgent,
441
691
  inspectClient,
692
+ materializeResume,
442
693
  prepareResume,
694
+ resumeConfirmation,
443
695
  resumeCacheName,
444
696
  resumeValidationStatus,
445
697
  setupAgent,
446
698
  validateResumeFile,
699
+ verifyPreparedResume,
447
700
  };
package/lib/client.js CHANGED
@@ -680,12 +680,12 @@ function downloadFile(endpoint, userAgent = null, _isRetry = false) {
680
680
  return;
681
681
  }
682
682
  const disposition = String(res.headers['content-disposition'] || '');
683
- const match = disposition.match(/filename="?([^";]+)"?/i);
684
683
  resolve({
685
684
  buffer,
686
685
  contentType: String(res.headers['content-type'] || 'application/octet-stream').split(';')[0],
687
- fileName: match ? match[1] : null,
686
+ fileName: contentDispositionFileName(disposition),
688
687
  sha256: res.headers['x-trackly-file-sha256'] || null,
688
+ applyRunId: res.headers['x-trackly-apply-run-id'] || null,
689
689
  });
690
690
  });
691
691
  });
@@ -695,6 +695,24 @@ function downloadFile(endpoint, userAgent = null, _isRetry = false) {
695
695
  });
696
696
  }
697
697
 
698
+ function contentDispositionFileName(disposition) {
699
+ const value = String(disposition || '');
700
+ const extended = value.match(/(?:^|;)\s*filename\*\s*=\s*(?:"([^"]*)"|([^;]*))/i);
701
+ if (extended) {
702
+ const encoded = extended[1] != null ? extended[1] : String(extended[2] ?? '').trim();
703
+ const match = encoded.match(/^([^']*)'[^']*'(.*)$/s);
704
+ const charset = match ? match[1].toLowerCase() : 'utf-8';
705
+ const payload = match ? match[2] : encoded;
706
+ if (charset === 'utf-8' || charset === 'utf8') {
707
+ try { return decodeURIComponent(payload); } catch (_) {}
708
+ }
709
+ }
710
+ const basic = value.match(/(?:^|;)\s*filename\s*=\s*(?:"([^"]*)"|([^;]*))/i);
711
+ if (!basic) return null;
712
+ const fileName = basic[1] != null ? basic[1] : String(basic[2] ?? '').trim();
713
+ return fileName || null;
714
+ }
715
+
698
716
  module.exports = {
699
717
  DEFAULT_BASE_URL,
700
718
  DEFAULT_CONFIG_DIR,
@@ -706,6 +724,7 @@ module.exports = {
706
724
  downloadFile,
707
725
  clearConfig,
708
726
  clearOAuthTokens,
727
+ contentDispositionFileName,
709
728
  ensureSecureUrl,
710
729
  getApiKey,
711
730
  getAuthHeader,
package/mcp/server.js CHANGED
@@ -5,7 +5,7 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio
5
5
  const { McpError } = require('@modelcontextprotocol/sdk/types.js');
6
6
  const { z } = require('zod');
7
7
  const { apiRequest, hasAuth, maintenanceOutput } = require('../lib/client');
8
- const { prepareResume } = require('../lib/agent');
8
+ const { prepareResume, verifyPreparedResume } = require('../lib/agent');
9
9
  const { version: PACKAGE_VERSION } = require('../package.json');
10
10
 
11
11
  const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
@@ -40,6 +40,10 @@ const STATUS_VALUES = ['new', 'applied_confirmed', 'check_later', 'not_intereste
40
40
  // `jobModality` enum matches `jobscout.ts:2870-2875`. Employment type, NOT work-location.
41
41
  const JOB_MODALITIES = ['full_time', 'internship', 'all'];
42
42
 
43
+ // Independent from geography and employment type. Matches the backend's
44
+ // workArrangements query contract and job_postings constraint.
45
+ const WORK_ARRANGEMENTS = ['remote', 'hybrid', 'in_person', 'unspecified'];
46
+
43
47
  // `sort` enum matches backend handler at `jobscout.ts:3053` — NOT the pre-fix
44
48
  // `newest|oldest|company` (backend rejects oldest/company with HTTP 400).
45
49
  const SORT_VALUES = ['newest', 'match'];
@@ -126,7 +130,7 @@ function createServer() {
126
130
 
127
131
  server.tool(
128
132
  'trackly_search_jobs',
129
- 'Search and filter job postings. Returns matching jobs with title, company, location, and structured fields. Use companyId to filter jobs at a specific company (get companyId from trackly_search_companies first). Pass `remote: true` for remote-only jobs.',
133
+ 'Search and filter job postings. Returns matching jobs with title, company, location, and structured fields. Use companyId to filter jobs at a specific company (get companyId from trackly_search_companies first). Work arrangement is independent from region and employment type: use workArrangements for remote, hybrid, in-person, or unspecified classifications.',
130
134
  {
131
135
  function: z.enum(JOB_FUNCTIONS).optional().describe('Job function filter. One of: ' + JOB_FUNCTIONS.join(', ')),
132
136
  companyId: z.number().optional().describe('Filter jobs by company ID (get from trackly_search_companies)'),
@@ -138,7 +142,10 @@ function createServer() {
138
142
  "Region tag filter. Pass ONE of: (a) a single scalar from 'us', 'non_us', 'all', or a REGION_TAGS value ('europe', 'latam', 'middle_east', 'asia', 'africa', 'canada', 'oceania', 'remote', 'unknown'); or (b) an array of region tags for multi-region (e.g. ['europe', 'canada']). The array form excludes 'us' — combining 'us' with other tags causes the backend to silently drop the others. For 'not US' use the scalar 'non_us' alone."
139
143
  ),
140
144
  jobModality: z.enum(JOB_MODALITIES).optional().describe(
141
- "Employment type (NOT work-location style). full_time = full-time roles, internship = internships, all = both. For remote filtering, use the `remote` boolean or locationFilter='remote'. Hybrid and onsite are not exposed as filters."
145
+ 'Employment type (NOT work arrangement). full_time = full-time roles, internship = internships, all = both. Use workArrangements for remote, hybrid, or in-person classification.'
146
+ ),
147
+ workArrangements: z.array(z.enum(WORK_ARRANGEMENTS)).min(1).max(4).optional().describe(
148
+ 'Work arrangement filter, independent from geography and employment type. Values: remote, hybrid, in_person, unspecified. Multiple values use OR semantics.'
142
149
  ),
143
150
  remote: z.boolean().optional().describe('Filter to remote jobs only (maps to usStates=REMOTE).'),
144
151
  status: z.enum(STATUS_VALUES).optional().describe(
@@ -168,6 +175,7 @@ function createServer() {
168
175
  qs.set('locationFilter', value);
169
176
  }
170
177
  if (params.jobModality !== undefined) qs.set('jobModality', params.jobModality);
178
+ if (params.workArrangements !== undefined) qs.set('workArrangements', params.workArrangements.join(','));
171
179
  if (params.remote === true) qs.set('usStates', 'REMOTE');
172
180
  if (params.status !== undefined) qs.set('status', params.status);
173
181
  if (params.sort !== undefined) qs.set('sort', params.sort);
@@ -461,9 +469,23 @@ function createServer() {
461
469
 
462
470
  server.tool(
463
471
  'trackly_prepare_resume',
464
- 'Download the authenticated default resume into a mode-0600 temporary Trackly cache for browser upload.',
465
- {},
466
- wrapTool(async () => prepareResume(), 'Failed to prepare default resume')
472
+ 'Download the authenticated default resume into a mode-0600 temporary Trackly cache and return exact-file proof for user confirmation before browser upload.',
473
+ { runId: z.number().int().min(1) },
474
+ wrapTool(async ({ runId }) => prepareResume(runId), 'Failed to prepare default resume')
475
+ );
476
+
477
+ server.tool(
478
+ 'trackly_verify_prepared_resume',
479
+ 'Immediately before attachment, recompute the prepared resume fingerprint, validate its run and expiration, and lock the confirmed file read-only.',
480
+ {
481
+ runId: z.number().int().min(1),
482
+ confirmationId: z.string().min(1).max(200),
483
+ exactLocalPath: z.string().min(1).max(4096),
484
+ sha256: z.string().regex(/^[a-f0-9]{64}$/i),
485
+ sizeBytes: z.number().int().min(1),
486
+ expiresAt: z.string().datetime(),
487
+ },
488
+ wrapTool(async (proof) => verifyPreparedResume(proof), 'Prepared resume integrity verification failed')
467
489
  );
468
490
 
469
491
  server.registerPrompt('trackly-apply', {
@@ -474,7 +496,7 @@ function createServer() {
474
496
  role: 'user',
475
497
  content: {
476
498
  type: 'text',
477
- text: 'Fetch the Trackly Apply protocol and profile onboarding, resolve missing answers with me, start the next approved queue run, fill the form, verify every required field, and stop before Submit. If maintenance interrupts the run, retain the run and browser context, wait for the advertised window, refetch protocol and profile state, and resume the existing agent_browser run. Never start a duplicate run, blindly retry a mutation, or click Submit.',
499
+ text: 'Fetch the Trackly Apply protocol and profile onboarding, resolve missing answers with me, and start the next approved queue run. Prepare the run-bound resume locally, show me its exact path, filename, size, SHA-256, run, and expiration, and obtain my explicit confirmation. Immediately before attachment, use the local verifier to validate the signed proof, recompute hash and size, check expiration, and lock the file read-only. Then fill the form, verify every required field, and stop before Submit. If maintenance interrupts the run, retain the run and browser context, wait for the advertised window, refetch protocol and profile state, and resume the existing agent_browser run. Never start a duplicate run, blindly retry a mutation, or click Submit.',
478
500
  },
479
501
  }],
480
502
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "mcpName": "io.github.trackly-app/trackly",
5
5
  "description": "AI job search CLI + hosted MCP server with OAuth. 128K+ jobs, 1,900+ companies, 40+ ATS. Works with Claude, ChatGPT, Cursor, Windsurf, Codex via hosted streamable-http or local stdio.",
6
6
  "bin": {
package/server.json CHANGED
@@ -17,12 +17,12 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "0.5.2",
20
+ "version": "0.6.1",
21
21
  "packages": [
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "trackly-cli",
25
- "version": "0.5.2",
25
+ "version": "0.6.1",
26
26
  "runtimeHint": "npx",
27
27
  "runtimeArguments": [
28
28
  {
@@ -30,7 +30,17 @@ Use Trackly as the source of truth for profile answers, documents, queue decisio
30
30
  4. Require the one-time profile confirmation, complete education entries, and a default resume before browser work.
31
31
  5. Call `trackly_get_apply_queue`. Select deterministically unless the user names a job. Do not replace the queue call with a fresh job search.
32
32
  6. Call `trackly_start_apply_run` for the selected job.
33
- 7. Call `trackly_prepare_resume`. If hosted MCP reports it unavailable, tell the user that local Trackly MCP or manual upload is required.
33
+ 7. Call `trackly_prepare_resume` with the active application run ID. If hosted MCP reports it unavailable, tell the user that local Trackly MCP or manual upload is required.
34
+ 8. Preserve the user’s filename returned by `trackly_prepare_resume`. Internal cache identifiers belong only in private parent directories and must never appear in the employer-facing upload filename.
35
+ 9. Before any upload, let the user inspect the exact prepared file returned by `trackly_prepare_resume`:
36
+ - Prefer an inline visual preview. Otherwise open the exact local file in Quick Look or Preview.app.
37
+ - Show a compact proof block with source (`Trackly default resume`), exact local path, user-facing filename, file size, SHA-256 fingerprint, application run, and expiration.
38
+ - Ask for explicit confirmation to use that resume. Bind confirmation to the exact SHA-256 and current application run; a different hash or run requires new confirmation.
39
+ - Always provide `confirmation.verification.exactLocalPath` so the user can independently verify the file. Never describe the prepared cache path as the original upload source.
40
+ - If an original local source path is known from the current session, identify it separately. Do not store original device paths in Trackly.
41
+ - A generic profile page is not proof of the prepared file. Use an app or web deep link only when the current protocol supplies an authenticated exact-resume viewer tied to the same SHA-256.
42
+ - If no exact preview method works, stop and ask the user to inspect the file manually.
43
+ 10. After the user confirms and immediately before attachment, call local `trackly_verify_prepared_resume` with the confirmed run ID, confirmation ID, exact path, SHA-256, size, and expiration. Continue only when it returns `verified: true` for exactly those values. The verifier validates the signed prepare-issued proof, recomputes the file hash, and locks it read-only. If it is unavailable, expired, missing, or mismatched, stop; prepare and visually confirm a fresh copy or require manual upload. Never send a local path or fingerprint to the hosted verifier.
34
44
 
35
45
  ## Resume after maintenance
36
46
 
@@ -50,7 +60,7 @@ Follow this order:
50
60
 
51
61
  1. Open the application in the controlled browser context and confirm the employer, role, ATS host, and HTTPS URL.
52
62
  2. Inspect the whole form and identify required fields, semantic controls, consent controls, document inputs, and multi-step sections.
53
- 3. Upload the prepared resume before autofill when parsing may overwrite contact fields. Verify the filename chip.
63
+ 3. Only after the exact-hash visual confirmation and a successful immediate pre-attach `trackly_verify_prepared_resume` check, upload the prepared resume before autofill when parsing may overwrite contact fields. Do not change the file between verification and attachment. Verify that the filename chip exactly matches the prepared resume’s user-facing filename and contains no internal cache identifier. Stop and replace the attachment if it does not.
54
64
  4. Fill typed fields from the resolved Trackly profile. Clear parser-filled data when the canonical state is intentionally blank.
55
65
  5. Use real UI clicks for React/native selects, radios, and checkboxes. After every selection, verify the committed value and disappearance of the required-field error.
56
66
  6. Recheck email and phone through both browser DOM state and macOS accessibility state. Require exact values and reject duplicate/concatenated values.
@@ -8,6 +8,10 @@ Ready for your review — not submitted
8
8
  Company / role:
9
9
  ATS / URL:
10
10
  Resume:
11
+ Resume visual confirmation: yes/no
12
+ Resume exact local path:
13
+ Resume fingerprint / size:
14
+ Resume confirmation run / expiration:
11
15
  Critical contact values verified: yes/no
12
16
  Authorization / sponsorship:
13
17
  Compensation answer: