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 +5 -3
- package/bin/trackly +20 -4
- package/contracts/trackly-apply-tools.json +6 -2
- package/docs/trackly-tools.md +7 -3
- package/lib/agent.js +301 -48
- package/lib/client.js +21 -2
- package/mcp/server.js +29 -7
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/trackly-apply/SKILL.md +12 -2
- package/skills/trackly-apply/references/review-handoff.md +4 -0
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 |
|
|
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,
|
|
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
|
|
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,
|
|
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": "
|
|
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
|
}
|
package/docs/trackly-tools.md
CHANGED
|
@@ -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
|
|
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`.
|
|
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 = '
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
310
|
-
|
|
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}
|
|
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(
|
|
607
|
+
const download = await downloadFile(
|
|
608
|
+
`/api/jobscout/application-profile/default-resume?runId=${normalizedRunId}`,
|
|
609
|
+
userAgent,
|
|
610
|
+
);
|
|
340
611
|
try {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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: {
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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.
|
|
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?.
|
|
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:
|
|
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).
|
|
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
|
-
|
|
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.
|
|
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.
|
|
20
|
+
"version": "0.6.1",
|
|
21
21
|
"packages": [
|
|
22
22
|
{
|
|
23
23
|
"registryType": "npm",
|
|
24
24
|
"identifier": "trackly-cli",
|
|
25
|
-
"version": "0.
|
|
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
|
|
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.
|
|
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:
|