trackly-cli 0.7.2 → 0.7.3

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 | 21 local MCP tools
38
+ 1,900+ companies | 128K+ jobs | 40+ ATS types | CLI + MCP | 24 local MCP tools
39
39
 
40
40
  ## CLI Commands
41
41
 
@@ -54,6 +54,9 @@ trackly companies search "fintech" # Semantic company search
54
54
  trackly search "fintech" # Alias for semantic company search
55
55
  trackly stats # Show metrics
56
56
  trackly status # Alias for stats
57
+ trackly preferences # Show discovery preferences and experience limits
58
+ trackly preferences experience product=2 strategy=5 # Replace role-specific limits
59
+ trackly preferences experience clear # Turn role-specific experience filtering off
57
60
  trackly apply 1234 # Mark as applied
58
61
  trackly save 1234 # Save a job
59
62
  trackly dismiss 1234 # Dismiss a job
@@ -78,6 +81,8 @@ trackly agent doctor # Verify setup, profile, resume, and compat
78
81
 
79
82
  Add `--json` to any command for JSON output. Use `--api-key <key>` or `--base-url <url>` as one-off global flags when needed.
80
83
 
84
+ `trackly preferences --json` returns only `success`, `experienceFilterV2Available`, and `preferences`. The availability flag controls whether this client may edit role-specific limits; it does not claim that feed or alert enforcement is active. Every update checks that flag and the latest preference revision before writing.
85
+
81
86
  ## MCP Server Setup
82
87
 
83
88
  ### Hosted (Claude co-work, Claude Desktop, ChatGPT)
@@ -160,6 +165,8 @@ Then use natural language in any of these clients:
160
165
  | trackly_search_companies | Semantic company search |
161
166
  | trackly_list_companies | List all tracked companies |
162
167
  | trackly_get_stats | Job tracker metrics and status counts |
168
+ | trackly_get_preferences | Read bounded discovery preferences, edit availability, and save revision |
169
+ | trackly_update_experience_limits | Atomically replace role-specific limits on a job's stated minimum years |
163
170
  | trackly_update_status | Mark jobs as applied, saved, or dismissed |
164
171
  | trackly_ask | Natural language job search (20/day) |
165
172
  | trackly_get_job_brief | Get network brief for a job (company signal, top contact, actions) |
@@ -188,9 +195,15 @@ trackly login
188
195
 
189
196
  Opens your browser for Google sign-in. Tokens are stored locally at `~/.trackly/config.json`.
190
197
 
198
+ Trackly is currently opening new memberships through a limited, invite-based
199
+ rollout. Existing members can keep using OAuth and API keys normally. If a new
200
+ account is not yet eligible, the CLI returns an invitation-specific message
201
+ instead of suggesting repeated login attempts; request a future seat at
202
+ [usetrackly.app/early-access](https://usetrackly.app/early-access).
203
+
191
204
  ### Option 2: API Key
192
205
 
193
- If OAuth doesn't work (firewalls, headless servers, CI), use an API key instead:
206
+ Existing members can use an API key for firewalls, headless servers, or CI:
194
207
 
195
208
  1. Sign in at [usetrackly.app](https://usetrackly.app)
196
209
  2. Go to **Settings → API Keys → Create**
@@ -250,7 +263,7 @@ Install trackly-cli (`npm install -g trackly-cli`), authenticate with `trackly l
250
263
 
251
264
  **What MCP servers exist for job searching?**
252
265
 
253
- trackly-cli includes a built-in MCP server with 22 tools for job search, company lookup, application tracking, profile onboarding, beta evidence, and manual-submit form preparation. Run `trackly mcp` or use `trackly agent setup --client claude`.
266
+ trackly-cli includes a built-in MCP server with 24 tools for job search, company lookup, discovery preferences, application tracking, profile onboarding, beta evidence, and manual-submit form preparation. Run `trackly mcp` or use `trackly agent setup --client claude`.
254
267
 
255
268
  **How do I use Claude Code for job hunting?**
256
269
 
package/bin/trackly CHANGED
@@ -5,6 +5,7 @@ const {
5
5
  DEFAULT_BASE_URL,
6
6
  apiRequest,
7
7
  clearConfig,
8
+ createTracklyAccessError,
8
9
  getApiKey,
9
10
  getAuthMethod,
10
11
  getBaseUrl,
@@ -65,6 +66,7 @@ const COMMAND_FLAGS = {
65
66
  search: [],
66
67
  stats: [],
67
68
  status: [],
69
+ preferences: [],
68
70
  apply: [],
69
71
  save: [],
70
72
  dismiss: [],
@@ -80,15 +82,32 @@ const COMMAND_FLAGS = {
80
82
  agent: ['client', 'window-days', 'target-reviewed-runs'],
81
83
  };
82
84
  const AUTH_HELP_TEXT = `Not authenticated.
83
- Option 1: trackly login (Google OAuth)
84
- Option 2: trackly config --api-key trk_xxx (get key at usetrackly.app)
85
- Option 3: TRACKLY_API_KEY=trk_xxx trackly ... (environment variable)`;
85
+ Existing member: trackly login (Google OAuth)
86
+ Existing API key: trackly config --api-key trk_xxx
87
+ New member: Trackly is in a limited rollout. Request access at
88
+ https://usetrackly.app/early-access`;
86
89
  const STATUS_LABELS = {
87
90
  applied: 'applied',
88
91
  saved: 'saved',
89
92
  dismissed: 'dismissed',
90
93
  };
91
94
  const WORK_ARRANGEMENT_VALUES = ['remote', 'hybrid', 'in_person', 'unspecified'];
95
+ const JOB_FUNCTIONS = [
96
+ 'product', 'engineering', 'design', 'data', 'marketing', 'sales', 'partnerships',
97
+ 'finance', 'strategy', 'operations', 'people', 'legal', 'support', 'other',
98
+ ];
99
+
100
+ function loginAccessError(code) {
101
+ return createTracklyAccessError({ code });
102
+ }
103
+
104
+ function writeLoginProgress(message) {
105
+ if (isJSON()) {
106
+ console.error(message);
107
+ } else {
108
+ console.log(message);
109
+ }
110
+ }
92
111
 
93
112
  function parseArgs(args) {
94
113
  const result = { _: [], command: null };
@@ -225,15 +244,20 @@ function formatMaintenanceForHuman(maintenance) {
225
244
 
226
245
  function setApiErrorExit(error, fallbackMessage, exitCode = 1) {
227
246
  const maintenance = maintenanceOutput(error);
228
- if (!maintenance) {
229
- setErrorExit(error?.error || error?.message || fallbackMessage, exitCode);
230
- return;
231
- }
232
-
233
- if (isJSON()) {
247
+ const accessError = createTracklyAccessError(error, error?.status);
248
+ if (isJSON() && accessError) {
249
+ printJson({
250
+ status: accessError.status,
251
+ code: accessError.code,
252
+ message: accessError.message,
253
+ retryable: accessError.retryable,
254
+ });
255
+ } else if (maintenance && isJSON()) {
234
256
  printJson(maintenance);
235
- } else {
257
+ } else if (maintenance) {
236
258
  outputError(formatMaintenanceForHuman(maintenance));
259
+ } else {
260
+ outputError(error?.error || error?.message || fallbackMessage);
237
261
  }
238
262
  process.exitCode = exitCode;
239
263
  }
@@ -252,6 +276,39 @@ function parseJobId(value) {
252
276
  return id;
253
277
  }
254
278
 
279
+ function parseExperienceLimits(values) {
280
+ if (!Array.isArray(values) || values.length === 0) {
281
+ throw new Error('Provide at least one role limit or `clear` (for example, `product=2 strategy=5`).');
282
+ }
283
+ if (values.includes('clear')) {
284
+ if (values.length !== 1) throw new Error('`clear` cannot be combined with role limits.');
285
+ return {};
286
+ }
287
+
288
+ const limits = {};
289
+ for (const value of values) {
290
+ const match = /^([^=]+)=([^=]+)$/.exec(String(value));
291
+ if (!match) throw new Error(`Invalid limit "${value}". Use role=years (for example, product=2).`);
292
+ const jobFunction = match[1].trim().toLowerCase();
293
+ const rawYears = match[2].trim();
294
+ if (!JOB_FUNCTIONS.includes(jobFunction)) {
295
+ throw new Error(`Unknown job function "${jobFunction}". Use: ${JOB_FUNCTIONS.join(', ')}.`);
296
+ }
297
+ if (Object.prototype.hasOwnProperty.call(limits, jobFunction)) {
298
+ throw new Error(`Duplicate limit for ${jobFunction}. Provide each job function once.`);
299
+ }
300
+ if (!/^\d+$/.test(rawYears)) {
301
+ throw new Error(`Limit for ${jobFunction} must be an integer from 0 to 60.`);
302
+ }
303
+ const years = Number(rawYears);
304
+ if (!Number.isInteger(years) || years < 0 || years > 60) {
305
+ throw new Error(`Limit for ${jobFunction} must be an integer from 0 to 60.`);
306
+ }
307
+ limits[jobFunction] = years;
308
+ }
309
+ return limits;
310
+ }
311
+
255
312
  // Flags deprecated in the API-docs-plan fixup. Still accepted by parseArgs so old
256
313
  // scripts don't ERR_NOT_FOUND, but cmdJobs hard-rejects them with a migration hint
257
314
  // — silent no-op would be worse than noise (per PR #13 Codex P1 finding).
@@ -321,11 +378,11 @@ function getPublicConfig() {
321
378
 
322
379
  // -- Commands --
323
380
 
324
- async function cmdLogin() {
381
+ async function cmdLogin({ openUrl = openExternalUrl } = {}) {
325
382
  const baseUrl = getBaseUrl();
326
383
  const state = crypto.randomBytes(16).toString('hex');
327
384
 
328
- console.log(color('cyan', 'Opening browser for Google login...'));
385
+ writeLoginProgress(color('cyan', 'Opening browser for Google login...'));
329
386
 
330
387
  let activeServer = null;
331
388
 
@@ -348,6 +405,21 @@ async function cmdLogin() {
348
405
 
349
406
  const token = url.searchParams.get('token');
350
407
  const refreshToken = url.searchParams.get('refreshToken');
408
+ const accessError = loginAccessError(url.searchParams.get('error'));
409
+ if (accessError) {
410
+ res.writeHead(403, {
411
+ 'Content-Type': 'text/html',
412
+ 'Referrer-Policy': 'no-referrer',
413
+ 'Cache-Control': 'no-store',
414
+ });
415
+ res.end('<html><body><h2>Trackly access is limited right now.</h2><p>Return to your terminal for next steps.</p></body></html>');
416
+ const loginError = new Error(accessError.message);
417
+ loginError.code = accessError.code;
418
+ loginError.status = accessError.status;
419
+ loginError.retryable = accessError.retryable;
420
+ settle(loginError);
421
+ return;
422
+ }
351
423
  if (token) {
352
424
  const config = loadConfig();
353
425
  const hadApiKey = Boolean(config.apiKey);
@@ -382,9 +454,9 @@ p{color:#a1a1aa;font-size:15px;line-height:1.5}
382
454
  </div>
383
455
  </body>
384
456
  </html>`);
385
- console.log(color('green', 'Login successful!'));
457
+ writeLoginProgress(color('green', 'Login successful!'));
386
458
  if (hadApiKey) {
387
- console.log('Switched to OAuth. Stored API key cleared.');
459
+ writeLoginProgress('Switched to OAuth. Stored API key cleared.');
388
460
  }
389
461
  settle();
390
462
  } else {
@@ -405,10 +477,12 @@ p{color:#a1a1aa;font-size:15px;line-height:1.5}
405
477
 
406
478
  const finish = () => {
407
479
  if (error) {
408
- console.log('\nLogin failed. Try API key auth instead:');
409
- console.log(' 1. Sign in at https://usetrackly.app');
410
- console.log(' 2. Go to Settings → API Keys → Create');
411
- console.log(' 3. Run: trackly config --api-key <your-key>');
480
+ if (!createTracklyAccessError(error, 403)) {
481
+ writeLoginProgress('\nLogin failed. Existing members can try API key auth:');
482
+ writeLoginProgress(' 1. Sign in at https://usetrackly.app');
483
+ writeLoginProgress(' 2. Go to Settings → API Keys → Create');
484
+ writeLoginProgress(' 3. Run: trackly config --api-key <your-key>');
485
+ }
412
486
  reject(error);
413
487
  } else {
414
488
  resolve();
@@ -436,10 +510,10 @@ p{color:#a1a1aa;font-size:15px;line-height:1.5}
436
510
  settle(new Error(`OS assigned an out-of-range callback port (${port}); expected 1024-65535.`));
437
511
  return;
438
512
  }
439
- console.log(color('dim', `Callback server on port ${port}`));
513
+ writeLoginProgress(color('dim', `Callback server on port ${port}`));
440
514
  const loginUrl = `${baseUrl}/auth/google/cli?port=${port}&state=${state}`;
441
- if (!openExternalUrl(loginUrl)) {
442
- console.log(`Open this URL in your browser:\n${loginUrl}`);
515
+ if (!openUrl(loginUrl)) {
516
+ writeLoginProgress(`Open this URL in your browser:\n${loginUrl}`);
443
517
  }
444
518
  });
445
519
 
@@ -461,6 +535,7 @@ p{color:#a1a1aa;font-size:15px;line-height:1.5}
461
535
  process.on('SIGINT', onSigint);
462
536
  try {
463
537
  await tryLogin();
538
+ if (isJSON()) printJson({ success: true, authMethod: 'oauth' });
464
539
  } finally {
465
540
  process.removeListener('SIGINT', onSigint);
466
541
  }
@@ -612,6 +687,119 @@ async function cmdStats() {
612
687
  }
613
688
  }
614
689
 
690
+ function renderPreferences(result) {
691
+ const user = result?.user || {};
692
+ const preferences = user.preferences || result?.preferences || {};
693
+ const limits = preferences.experienceLimitsByJobFunction || {};
694
+
695
+ console.log(color('bold', 'Discovery preferences'));
696
+ const selected = Array.isArray(preferences.wantedJobFunctions)
697
+ ? preferences.wantedJobFunctions
698
+ : [];
699
+ console.log(` Target roles: ${selected.length > 0 ? selected.join(', ') : 'All roles'}`);
700
+ console.log(` Experience limits: ${Object.keys(limits).length > 0 ? 'Configured' : 'Not configured'}`);
701
+ for (const [jobFunction, years] of Object.entries(limits)) {
702
+ console.log(` ${jobFunction}: Up to ${years} year${years === 1 ? '' : 's'} required`);
703
+ }
704
+ console.log(color('dim', ' Jobs with no stated minimum remain visible.'));
705
+ console.log(color('dim', ' This confirms what you saved; it does not show whether Trackly is currently applying the limits.'));
706
+ if (Number.isSafeInteger(preferences.discoveryPreferenceRevision)) {
707
+ console.log(color('dim', ` Revision: ${preferences.discoveryPreferenceRevision}`));
708
+ }
709
+ }
710
+
711
+ function projectPreferenceResponse(result, experienceFilterV2Available = null) {
712
+ const user = result?.user || {};
713
+ return {
714
+ success: result?.success === true,
715
+ experienceFilterV2Available: experienceFilterV2Available === null
716
+ ? user.experienceFilterV2Available === true
717
+ : experienceFilterV2Available === true,
718
+ preferences: user.preferences || result?.preferences || {},
719
+ };
720
+ }
721
+
722
+ function handlePreferenceConflict(error) {
723
+ const message = 'Preferences changed on another device. Review `trackly preferences`, then run the update again.';
724
+ if (isJSON()) {
725
+ printJson({
726
+ status: error.status,
727
+ code: error.error,
728
+ message,
729
+ preferences: error.preferences,
730
+ discoveryPreferenceRevision: error.discoveryPreferenceRevision,
731
+ });
732
+ } else {
733
+ outputError(message);
734
+ }
735
+ process.exitCode = 1;
736
+ }
737
+
738
+ async function cmdPreferences(args) {
739
+ if (!hasAuth()) failNow(AUTH_HELP_TEXT);
740
+
741
+ const subcommand = args._[0];
742
+ if (subcommand == null || subcommand === 'show') {
743
+ if (args._.length > (subcommand === 'show' ? 1 : 0)) {
744
+ failNow('Usage: trackly preferences [show]');
745
+ }
746
+ try {
747
+ const result = await apiRequest('GET', '/api/jobscout/me');
748
+ if (isJSON()) return printJson(projectPreferenceResponse(result));
749
+ renderPreferences(result);
750
+ return;
751
+ } catch (error) {
752
+ setApiErrorExit(error, 'Failed to fetch preferences');
753
+ return;
754
+ }
755
+ }
756
+
757
+ if (subcommand !== 'experience') {
758
+ failNow('Unknown preferences subcommand. Use: trackly preferences [show] | trackly preferences experience <role=years...|clear>');
759
+ }
760
+
761
+ let experienceLimitsByJobFunction;
762
+ try {
763
+ experienceLimitsByJobFunction = parseExperienceLimits(args._.slice(1));
764
+ } catch (error) {
765
+ failNow(error.message);
766
+ }
767
+
768
+ try {
769
+ // Read immediately before the write and send the authoritative revision.
770
+ // The backend rejects a stale value with 409 instead of letting this
771
+ // process overwrite a newer iOS, web, macOS, CLI, or MCP save.
772
+ const current = await apiRequest('GET', '/api/jobscout/me');
773
+ if (current?.user?.experienceFilterV2Available !== true) {
774
+ failNow('Role-specific experience preferences are not available for this account yet. No changes were saved.');
775
+ }
776
+ const expectedPreferenceRevision = current?.user?.preferences?.discoveryPreferenceRevision;
777
+ if (!Number.isSafeInteger(expectedPreferenceRevision) || expectedPreferenceRevision < 0) {
778
+ failNow('Trackly did not return a valid preference revision. No changes were saved.');
779
+ }
780
+
781
+ const result = await apiRequest('PUT', '/api/jobscout/preferences', {
782
+ experienceLimitsByJobFunction,
783
+ experienceFilterVersion: 2,
784
+ expectedPreferenceRevision,
785
+ });
786
+ if (isJSON()) return printJson(projectPreferenceResponse(result, true));
787
+ console.log(color(
788
+ 'green',
789
+ Object.keys(experienceLimitsByJobFunction).length > 0
790
+ ? 'Role-specific experience limits updated.'
791
+ : 'Role-specific experience filtering turned off.',
792
+ ));
793
+ renderPreferences(result);
794
+ } catch (error) {
795
+ if (error?.status === 409 && error?.error === 'preference_revision_conflict') {
796
+ handlePreferenceConflict(error);
797
+ return;
798
+ }
799
+ setApiErrorExit(error, 'Failed to update experience limits');
800
+ }
801
+ }
802
+
615
803
  const ACTION_TO_COMMAND = { applied: 'apply', saved: 'save', dismissed: 'dismiss' };
616
804
 
617
805
  // CLI surface accepts applied|saved|dismissed (legacy human-friendly terms).
@@ -1064,6 +1252,8 @@ ${color('bold', 'Commands:')}
1064
1252
  ${color('cyan', 'search <query>')} Semantic company search
1065
1253
  ${color('cyan', 'stats')} Show metrics dashboard
1066
1254
  ${color('cyan', 'status')} Alias for stats
1255
+ ${color('cyan', 'preferences')} Show discovery preferences
1256
+ ${color('cyan', 'preferences experience')} Replace role limits (role=years; clear turns off)
1067
1257
  ${color('cyan', 'apply <id>')} Mark job as applied
1068
1258
  ${color('cyan', 'save <id>')} Save a job
1069
1259
  ${color('cyan', 'dismiss <id>')} Dismiss a job
@@ -1095,6 +1285,10 @@ ${color('bold', 'Flags:')}
1095
1285
  ${color('bold', 'Filter values:')}
1096
1286
  --function product, engineering, design, data, marketing, sales, partnerships,
1097
1287
  finance, strategy, operations, people, legal, support, other (comma-separated)
1288
+ experience limits use the same role values, for example:
1289
+ trackly preferences experience product=2 strategy=5
1290
+ When enforcement is active, includes jobs whose stated minimum is up to the limit.
1291
+ Jobs with no stated minimum remain visible.
1098
1292
  --region us, non_us, all, or a region tag (europe, canada, remote, ...)
1099
1293
  Use a comma-separated list of region tags for multiple regions;
1100
1294
  us/non_us/all cannot be combined.
@@ -1208,6 +1402,9 @@ async function main() {
1208
1402
  case 'status':
1209
1403
  await cmdStats();
1210
1404
  break;
1405
+ case 'preferences':
1406
+ await cmdPreferences(args);
1407
+ break;
1211
1408
  case 'apply':
1212
1409
  await cmdUpdateStatus(args._[0], 'applied');
1213
1410
  break;
@@ -1276,14 +1473,18 @@ if (require.main === module) {
1276
1473
  module.exports = {
1277
1474
  COMMAND_FLAGS,
1278
1475
  UNIVERSAL_FLAGS,
1476
+ cmdLogin,
1279
1477
  formatMaintenanceForHuman,
1280
1478
  getPublicConfig,
1281
1479
  hasJobListFilters,
1282
1480
  levenshtein,
1481
+ loginAccessError,
1283
1482
  main,
1284
1483
  nearestFlag,
1285
1484
  normalizeBaseUrlValue,
1286
1485
  openExternalUrl,
1287
1486
  parseArgs,
1487
+ parseExperienceLimits,
1288
1488
  parseJobId,
1489
+ writeLoginProgress,
1289
1490
  };
@@ -65,6 +65,11 @@ trackly config --api-key trk_xxxxxxxxxxxxxxxxxxxx
65
65
 
66
66
  You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
67
67
 
68
+ Existing memberships work across the CLI and local MCP server. New memberships
69
+ are invite-only during the limited rollout; access errors link to
70
+ https://usetrackly.app/early-access instead of recommending repeated OAuth or
71
+ API-key creation attempts.
72
+
68
73
  ### Available Tools
69
74
 
70
75
  - **trackly_search_jobs** — Search/filter jobs.
@@ -82,6 +87,12 @@ You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
82
87
  - **trackly_search_companies** — Semantic company search
83
88
  - **trackly_list_companies** — List all tracked companies with job counts
84
89
  - **trackly_get_stats** — Job tracker metrics dashboard
90
+ - **trackly_get_preferences** — Read a bounded preference response containing only `success`, `experienceFilterV2Available`, and the authenticated user's discovery `preferences`, including selected roles, saved role-specific limits, and the revision required for a safe update. Availability authorizes editing; it does not report feed or alert enforcement.
91
+ - **trackly_update_experience_limits** — Atomically replace the complete role-specific experience-limit map.
92
+ - `experienceLimitsByJobFunction`: object whose keys are the 14 canonical job-function values and whose values are integer years from `0` through `60`. An empty object turns this filter off.
93
+ - `expectedPreferenceRevision`: non-negative safe integer from the latest `trackly_get_preferences` result. A stale revision is rejected; refetch and reconcile with the user rather than retrying blindly.
94
+ - The tool checks `experienceFilterV2Available` immediately before writing and refuses without a PUT unless it is exactly `true`.
95
+ - When server-side enforcement is active, a job remains visible when its stated minimum is less than or equal to that role's limit. Jobs with no stated minimum remain visible.
85
96
  - **trackly_update_status** — Mark a job as applied, saved, or dismissed
86
97
  - **trackly_ask** — Natural language job search (20/day limit)
87
98
  - **trackly_get_job_brief** — Get network brief for a job (company signal, top contact, actions)
@@ -114,4 +125,5 @@ REST, refresh, download, CLI, and local/hosted MCP surfaces use canonical `code:
114
125
  - "What remote engineering roles are available?"
115
126
  - "Show me jobs at Stripe"
116
127
  - "Mark job 1234 as applied"
128
+ - "Show my Trackly preferences, then set Product to up to 2 years and Strategy to up to 5 years"
117
129
  - "Use Trackly Apply to fill my next saved application and stop before Submit"
package/lib/client.js CHANGED
@@ -18,6 +18,22 @@ const MAINTENANCE_APPLY_GUIDANCE =
18
18
  'Do not blindly retry an Apply mutation. Wait for the advertised maintenance window, ' +
19
19
  'refetch the Apply protocol and application profile, then resume the existing agent_browser run. ' +
20
20
  'Never create a duplicate run or click Submit.';
21
+ const TRACKLY_ACCESS_GUIDANCE =
22
+ 'Trackly is in a limited rollout. Existing members can sign in; new members need a private invite. ' +
23
+ 'Request access at https://usetrackly.app/early-access.';
24
+ const TRACKLY_ACCESS_PENDING_GUIDANCE =
25
+ 'Your Trackly access request is already pending. No new request is needed.';
26
+ const TRACKLY_ACCESS_UNAVAILABLE_GUIDANCE =
27
+ 'Trackly access could not be verified right now. Please try again.';
28
+ const TRACKLY_ACCESS_CODES = new Set([
29
+ 'INVITATION_REQUIRED',
30
+ 'INVITATION_INVALID',
31
+ 'INVITATION_REDEEMED',
32
+ 'SIGNUP_INTENT_EXPIRED',
33
+ 'ACCESS_BATCH_FULL',
34
+ 'ACCESS_CHECK_UNAVAILABLE',
35
+ 'ALREADY_REQUESTED',
36
+ ]);
21
37
 
22
38
  class TracklyConfigError extends Error {
23
39
  constructor(message) {
@@ -243,6 +259,27 @@ function nestedRecord(value) {
243
259
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
244
260
  }
245
261
 
262
+ function createTracklyAccessError(json, status) {
263
+ const record = nestedRecord(json);
264
+ const code = cleanText(record.code);
265
+ if (!code || !TRACKLY_ACCESS_CODES.has(code.toUpperCase())) return null;
266
+ const normalizedCode = code.toUpperCase();
267
+ const retryable = normalizedCode === 'ACCESS_CHECK_UNAVAILABLE';
268
+ const message = retryable
269
+ ? TRACKLY_ACCESS_UNAVAILABLE_GUIDANCE
270
+ : normalizedCode === 'ALREADY_REQUESTED'
271
+ ? TRACKLY_ACCESS_PENDING_GUIDANCE
272
+ : TRACKLY_ACCESS_GUIDANCE;
273
+ return {
274
+ ...record,
275
+ status: status ?? (retryable ? 503 : 403),
276
+ code: normalizedCode,
277
+ error: message,
278
+ message,
279
+ retryable,
280
+ };
281
+ }
282
+
246
283
  // REST maintenance responses and hosted MCP JSON-RPC errors intentionally use
247
284
  // different envelopes. Collapse both into the same source view so every CLI
248
285
  // surface preserves equivalent code/retry/ETA/request context.
@@ -579,6 +616,8 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
579
616
  headers: res.headers,
580
617
  });
581
618
  if (maintenanceError) return reject(maintenanceError);
619
+ const accessError = createTracklyAccessError(json, res.statusCode);
620
+ if (accessError) return reject(accessError);
582
621
  // Spread `json` FIRST so the HTTP statusCode always wins. A backend that returns
583
622
  // `{"status": "error"}` in the body would otherwise clobber the numeric status with
584
623
  // a string, breaking downstream branches like `typeof e.status === 'number'` in
@@ -676,6 +715,11 @@ function downloadFile(endpoint, userAgent = null, _isRetry = false) {
676
715
  reject(maintenanceError);
677
716
  return;
678
717
  }
718
+ const accessError = createTracklyAccessError(body, res.statusCode);
719
+ if (accessError) {
720
+ reject(accessError);
721
+ return;
722
+ }
679
723
  reject({ ...body, status: res.statusCode, message: body.error || `HTTP ${res.statusCode}` });
680
724
  return;
681
725
  }
@@ -720,6 +764,7 @@ module.exports = {
720
764
  TracklyConfigError,
721
765
  MAINTENANCE_APPLY_GUIDANCE,
722
766
  MAINTENANCE_CODE,
767
+ TRACKLY_ACCESS_GUIDANCE,
723
768
  apiRequest,
724
769
  downloadFile,
725
770
  clearConfig,
@@ -738,6 +783,7 @@ module.exports = {
738
783
  hasAuth,
739
784
  loadConfig,
740
785
  createMaintenanceError,
786
+ createTracklyAccessError,
741
787
  isMaintenanceError,
742
788
  maintenanceOutput,
743
789
  normalizeMaintenance,
package/mcp/server.js CHANGED
@@ -4,14 +4,18 @@ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
4
4
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
5
5
  const { McpError } = require('@modelcontextprotocol/sdk/types.js');
6
6
  const { z } = require('zod');
7
- const { apiRequest, hasAuth, maintenanceOutput } = require('../lib/client');
7
+ const { apiRequest, createTracklyAccessError, hasAuth, maintenanceOutput } = require('../lib/client');
8
8
  const { prepareResume, verifyPreparedResume } = require('../lib/agent');
9
9
  const { version: PACKAGE_VERSION } = require('../package.json');
10
10
  const APPLY_CONTRACT = require('../contracts/trackly-apply-tools.json');
11
11
 
12
12
  const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
13
13
  const MCP_MAINTENANCE_ERROR_CODE = -32002;
14
- const AUTH_HINT = 'Run `trackly login` or set TRACKLY_API_KEY. Get a key at https://usetrackly.app (sign in → Settings → API Keys).';
14
+ const MCP_ACCESS_ERROR_CODE = -32003;
15
+ const MCP_AUTH_ERROR_CODE = -32004;
16
+ const AUTH_HINT =
17
+ 'Existing members: run `trackly login` or set TRACKLY_API_KEY. ' +
18
+ 'New members need a private invite during the limited rollout; request access at https://usetrackly.app/early-access.';
15
19
  const APPLY_BROWSER_SURFACES = APPLY_CONTRACT.constants.applyBrowserSurfaces;
16
20
  const APPLY_SCENARIO_CODES = APPLY_CONTRACT.constants.applyScenarioCodes;
17
21
  const SAFE_OBSERVATION_CODE = /^[a-z0-9][a-z0-9_:-]{0,99}$/;
@@ -60,21 +64,44 @@ const ACTION_TO_STAGE = { applied: 'applied', saved: 'backlog', dismissed: 'disc
60
64
 
61
65
  function createErrorResult(error, fallbackMessage, extra = {}) {
62
66
  const normalizedMaintenance = maintenanceOutput(error);
67
+ const normalizedAccess = createTracklyAccessError(error, error?.status);
63
68
  const payload = normalizedMaintenance
64
69
  ? {
65
70
  ...normalizedMaintenance,
66
71
  error: error?.error || error?.message || fallbackMessage,
67
72
  ...extra,
68
73
  }
69
- : {
70
- error: error?.error || error?.message || fallbackMessage,
71
- ...extra,
72
- };
74
+ : normalizedAccess
75
+ ? {
76
+ error: normalizedAccess.error,
77
+ message: normalizedAccess.message,
78
+ status: normalizedAccess.status,
79
+ code: normalizedAccess.code,
80
+ retryable: normalizedAccess.retryable,
81
+ ...extra,
82
+ }
83
+ : {
84
+ error: error?.error || error?.message || fallbackMessage,
85
+ ...extra,
86
+ };
73
87
 
74
88
  if (error?.status) {
75
89
  payload.status = error.status;
76
90
  }
77
91
 
92
+ if (
93
+ error?.code === 'experience_filter_v2_unavailable'
94
+ || error?.code === 'invalid_preference_revision'
95
+ ) {
96
+ payload.code = error.code;
97
+ }
98
+
99
+ if (error?.status === 409 && error?.error === 'preference_revision_conflict') {
100
+ payload.preferences = error.preferences;
101
+ payload.discoveryPreferenceRevision = error.discoveryPreferenceRevision;
102
+ payload.hint = 'Preferences changed elsewhere. Refetch with trackly_get_preferences, reconcile with the user, and never retry blindly with the newer revision.';
103
+ }
104
+
78
105
  if (error?.status === 429 && !payload.hint) {
79
106
  payload.hint = 'Daily limit reached (20 natural language queries per day).';
80
107
  }
@@ -102,6 +129,25 @@ function throwMcpResourceError(error) {
102
129
  normalizedMaintenance,
103
130
  );
104
131
  }
132
+ const normalizedAccess = createTracklyAccessError(error, error?.status);
133
+ if (normalizedAccess) {
134
+ throw new McpError(
135
+ MCP_ACCESS_ERROR_CODE,
136
+ normalizedAccess.message,
137
+ {
138
+ status: normalizedAccess.status,
139
+ code: normalizedAccess.code,
140
+ retryable: normalizedAccess.retryable,
141
+ },
142
+ );
143
+ }
144
+ if (error?.status === 401) {
145
+ throw new McpError(
146
+ MCP_AUTH_ERROR_CODE,
147
+ error?.message || 'Not authenticated',
148
+ { status: 401, hint: AUTH_HINT },
149
+ );
150
+ }
105
151
  throw error;
106
152
  }
107
153
 
@@ -126,6 +172,17 @@ function wrapTool(handler, fallbackMessage) {
126
172
  };
127
173
  }
128
174
 
175
+ function projectPreferenceResponse(result, experienceFilterV2Available = null) {
176
+ const user = result?.user || {};
177
+ return {
178
+ success: result?.success === true,
179
+ experienceFilterV2Available: experienceFilterV2Available === null
180
+ ? user.experienceFilterV2Available === true
181
+ : experienceFilterV2Available === true,
182
+ preferences: user.preferences || result?.preferences || {},
183
+ };
184
+ }
185
+
129
186
  function createServer() {
130
187
  const server = new McpServer({
131
188
  name: 'trackly',
@@ -239,6 +296,62 @@ function createServer() {
239
296
  }, 'Failed to fetch stats')
240
297
  );
241
298
 
299
+ server.tool(
300
+ 'trackly_get_preferences',
301
+ 'Read the authenticated user\'s discovery preferences, including selected and effective job functions, role-specific experience limits and active state, and the discoveryPreferenceRevision required for a safe update.',
302
+ {},
303
+ wrapTool(async () => {
304
+ const result = await apiRequest('GET', '/api/jobscout/me', null, false, false, MCP_USER_AGENT);
305
+ return projectPreferenceResponse(result);
306
+ }, 'Failed to fetch preferences')
307
+ );
308
+
309
+ server.tool(
310
+ 'trackly_update_experience_limits',
311
+ 'Atomically replace the complete role-specific experience-limit map. Each value is the highest stated minimum years requirement the user wants for that job function. When Trackly enforcement is active, a job is included when its stated minimum is less than or equal to the limit. Jobs with no stated minimum remain visible. An empty object clears the saved limits. experienceFilterV2Available reports edit capability, not enforcement state. Read discoveryPreferenceRevision with trackly_get_preferences first. If the backend returns a revision conflict, refetch and reconcile with the user; never retry blindly with the newer revision.',
312
+ {
313
+ experienceLimitsByJobFunction: z.record(z.enum(JOB_FUNCTIONS), z.number().int().min(0).max(60)).describe(
314
+ 'Complete replacement map from canonical job-function slug to maximum acceptable stated minimum years. Use {} to turn role-specific experience filtering off.'
315
+ ),
316
+ expectedPreferenceRevision: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).describe(
317
+ 'The non-negative safe-integer discoveryPreferenceRevision returned by the latest trackly_get_preferences call. A stale revision is rejected instead of overwriting newer choices.'
318
+ ),
319
+ },
320
+ wrapTool(async ({ experienceLimitsByJobFunction, expectedPreferenceRevision }) => {
321
+ const current = await apiRequest('GET', '/api/jobscout/me', null, false, false, MCP_USER_AGENT);
322
+ if (current?.user?.experienceFilterV2Available !== true) {
323
+ throw {
324
+ status: 403,
325
+ code: 'experience_filter_v2_unavailable',
326
+ error: 'Role-specific experience preferences are not available for this account yet. No changes were saved.',
327
+ };
328
+ }
329
+ const currentPreferences = current?.user?.preferences || {};
330
+ const currentRevision = currentPreferences.discoveryPreferenceRevision;
331
+ if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) {
332
+ throw {
333
+ status: 502,
334
+ code: 'invalid_preference_revision',
335
+ error: 'Trackly did not return a valid preference revision. No changes were saved.',
336
+ };
337
+ }
338
+ if (currentRevision !== expectedPreferenceRevision) {
339
+ throw {
340
+ status: 409,
341
+ error: 'preference_revision_conflict',
342
+ preferences: currentPreferences,
343
+ discoveryPreferenceRevision: currentRevision,
344
+ };
345
+ }
346
+ const result = await apiRequest('PUT', '/api/jobscout/preferences', {
347
+ experienceLimitsByJobFunction,
348
+ experienceFilterVersion: 2,
349
+ expectedPreferenceRevision,
350
+ }, false, false, MCP_USER_AGENT);
351
+ return projectPreferenceResponse(result, true);
352
+ }, 'Failed to update experience limits')
353
+ );
354
+
242
355
  server.tool(
243
356
  'trackly_update_status',
244
357
  'Update a job application status (apply, save, or dismiss).',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
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.7.2",
20
+ "version": "0.7.3",
21
21
  "packages": [
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "trackly-cli",
25
- "version": "0.7.2",
25
+ "version": "0.7.3",
26
26
  "runtimeHint": "npx",
27
27
  "runtimeArguments": [
28
28
  {