trackly-cli 0.14.0 → 0.14.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
@@ -1,6 +1,6 @@
1
1
  [![npm](https://img.shields.io/npm/v/trackly-cli.svg)](https://www.npmjs.com/package/trackly-cli)
2
2
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
3
- [![Node 20+](https://img.shields.io/badge/node-20+-brightgreen.svg)](https://nodejs.org/)
3
+ [![Node 20.20+ or 22.22+](https://img.shields.io/badge/node-20.20%2B%20%7C%2022.22%2B-brightgreen.svg)](https://nodejs.org/)
4
4
  [![MCP Server](https://img.shields.io/badge/MCP-Server-blue.svg)](https://modelcontextprotocol.io/)
5
5
 
6
6
  # trackly-cli
@@ -31,11 +31,11 @@ trackly login
31
31
  trackly jobs --function product
32
32
  ```
33
33
 
34
- > **Prerequisites:** [Node.js 20+](https://nodejs.org/) (LTS recommended). On macOS with the official `.pkg` installer, global npm installs may require `sudo`.
34
+ > **Prerequisites:** [Node.js 20.20+ or 22.22+](https://nodejs.org/) (LTS recommended). On macOS with the official `.pkg` installer, global npm installs may require `sudo`.
35
35
 
36
36
  ## At a Glance
37
37
 
38
- 3,800+ companies | 170K+ jobs | 40+ ATS types | CLI + MCP | 54 local MCP tools
38
+ 3,800+ companies | 170K+ jobs | 40+ ATS types | CLI + MCP | 55 local MCP tools
39
39
 
40
40
  ## CLI Commands
41
41
 
@@ -215,6 +215,7 @@ Then use natural language in any of these clients:
215
215
  | trackly_record_application_outcomes | Bulk-record up to 20 leased batch outcomes with per-member conflicts |
216
216
  | trackly_prepare_resume | Prepare a private expiring resume file for upload |
217
217
  | trackly_verify_prepared_resume | Recheck the confirmed resume immediately before attachment |
218
+ | get_more_tools | Report a missing capability so Trackly can improve its MCP surface |
218
219
 
219
220
  ## Authentication
220
221
 
@@ -280,7 +281,7 @@ trackly config --base-url http://127.0.0.1:3000 # Point at a different backend
280
281
  | Job search + filters | Yes | Yes | Yes |
281
282
  | Apply/save/dismiss | Yes | Yes | Yes |
282
283
  | AI-powered search | Yes (trackly ask) | Yes | Yes |
283
- | MCP integration | Yes (54 local tools) | -- | -- |
284
+ | MCP integration | Yes (55 local tools) | -- | -- |
284
285
  | Browser required | No | Yes | No |
285
286
  | Best for | Terminal + AI agents | Visual browsing | Custom integrations |
286
287
 
@@ -294,7 +295,18 @@ Install trackly-cli (`npm install -g trackly-cli`), authenticate with `trackly l
294
295
 
295
296
  **What MCP servers exist for job searching?**
296
297
 
297
- trackly-cli includes a built-in MCP server with 54 tools for job search, company lookup, discovery preferences, application tracking, accessible execution and frozen-batch orchestration, profile onboarding, beta evidence, and manual-submit form preparation. Run `trackly mcp` or use `trackly agent setup --client claude`.
298
+ trackly-cli includes a built-in MCP server with 55 tools: the complete Trackly
299
+ job-search and application set plus `get_more_tools`, which lets agents report
300
+ a missing capability. Run `trackly mcp` or use
301
+ `trackly agent setup --client claude`.
302
+
303
+ MCP usage analytics are on by default and relay through Trackly so the free
304
+ service can be debugged and improved. They exclude résumé text, profile answers,
305
+ demographic or work-authorization answers, and application notes. Turn off future
306
+ collection with **Share usage analytics** in Account Settings. Operators may also
307
+ disable local MCP instrumentation with `TRACKLY_MCP_ANALYTICS_DISABLED=1` (or
308
+ `TRACKLY_MCP_ANALYTICS_ENABLED=0`); the account setting remains authoritative
309
+ for authenticated backend capture.
298
310
 
299
311
  **How do I use Claude Code for job hunting?**
300
312
 
package/bin/trackly CHANGED
@@ -4,7 +4,6 @@
4
4
  const {
5
5
  DEFAULT_BASE_URL,
6
6
  apiRequest,
7
- clearConfig,
8
7
  createTracklyAccessError,
9
8
  getApiKey,
10
9
  getAuthMethod,
@@ -13,6 +12,7 @@ const {
13
12
  hasAuth,
14
13
  maintenanceOutput,
15
14
  loadConfig,
15
+ removeCredentials,
16
16
  saveConfig,
17
17
  } = require('../lib/client');
18
18
  const { color, isJSON, outputJobs, outputCompanies, outputStats, outputContacts, outputReferralCampaign, outputNetworkBrief, outputCompanyBrief, outputCompanyWorkspace, outputError } = require('../lib/formatters');
@@ -544,7 +544,7 @@ p{color:#a1a1aa;font-size:15px;line-height:1.5}
544
544
  }
545
545
 
546
546
  async function cmdLogout() {
547
- clearConfig();
547
+ await removeCredentials({ scope: 'all' });
548
548
  console.log(color('green', 'Logged out. Credentials cleared.'));
549
549
  }
550
550
 
@@ -1055,11 +1055,12 @@ async function cmdConfig(args) {
1055
1055
  failNow('Cannot use --base-url and --clear-base-url together.');
1056
1056
  }
1057
1057
 
1058
- const config = loadConfig();
1058
+ let config = loadConfig();
1059
1059
  let updated = false;
1060
1060
 
1061
1061
  if (args['clear-api-key']) {
1062
- delete config.apiKey;
1062
+ await removeCredentials({ scope: 'apiKey' });
1063
+ config = loadConfig();
1063
1064
  updated = true;
1064
1065
  console.log(color('green', 'Stored API key cleared.'));
1065
1066
  }
@@ -1123,8 +1124,10 @@ async function cmdVersion() {
1123
1124
  console.log(VERSION);
1124
1125
  }
1125
1126
 
1126
- async function cmdMcp() {
1127
- await require('../mcp/server').startMcpServer();
1127
+ async function cmdMcp(options = {}) {
1128
+ const mcpServer = options.mcpServer || require('../mcp/server');
1129
+ const server = await mcpServer.startMcpServer(options.startOptions);
1130
+ return mcpServer.installMcpSignalHandlers(server, options.signalOptions);
1128
1131
  }
1129
1132
 
1130
1133
  async function cmdAgent(args) {
@@ -1504,6 +1507,7 @@ if (require.main === module) {
1504
1507
  module.exports = {
1505
1508
  COMMAND_FLAGS,
1506
1509
  UNIVERSAL_FLAGS,
1510
+ cmdMcp,
1507
1511
  cmdLogin,
1508
1512
  formatMaintenanceForHuman,
1509
1513
  getPublicConfig,
@@ -72,6 +72,7 @@ API-key creation attempts.
72
72
 
73
73
  ### Available Tools
74
74
 
75
+ - **get_more_tools** — Report what the agent wanted to do when no Trackly tool fit, so Trackly can improve its MCP surface. The tool is always available; report delivery is best-effort and occurs only when usage analytics are enabled.
75
76
  - **trackly_search_jobs** — Search/filter jobs.
76
77
  - `function` (14 values): `product`, `engineering`, `design`, `data`, `marketing`, `sales`, `partnerships`, `finance`, `strategy`, `operations`, `people`, `legal`, `support`, `other`
77
78
  - `locationFilter`: pass ONE of
package/lib/client.js CHANGED
@@ -10,6 +10,7 @@ const { version: PACKAGE_VERSION } = require('../package.json');
10
10
  const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.trackly');
11
11
  const DEFAULT_BASE_URL = 'https://closeai.mba';
12
12
  const DEFAULT_HTTP_TIMEOUT_MS = 30000;
13
+ const ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS = 1500;
13
14
  const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
14
15
  const MAINTENANCE_CODE = 'maintenance_mode';
15
16
  const LEGACY_MAINTENANCE_CODE = 'planned_maintenance';
@@ -106,10 +107,180 @@ function saveConfig(config) {
106
107
  }
107
108
 
108
109
  function clearConfig() {
110
+ let config = {};
111
+ try {
112
+ const loaded = loadConfig();
113
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) config = loaded;
114
+ } catch {
115
+ // Logout must still remove an invalid or unreadable credential file. The
116
+ // opt-out can be preserved only when the existing config is recoverable.
117
+ }
118
+ // A logout clears credentials and endpoint overrides, but the content-free
119
+ // analytics opt-out must continue suppressing anonymous MCP lifecycle events.
120
+ if (config.mcpAnalyticsOptOut === true) {
121
+ try {
122
+ saveConfig({ mcpAnalyticsOptOut: true });
123
+ return;
124
+ } catch {
125
+ // Preserving the marker is best-effort. A disk/write failure must never
126
+ // leave credentials behind, so fall through to unlinking the old file.
127
+ }
128
+ }
109
129
  const { file } = getConfigPaths();
110
130
  try { fs.unlinkSync(file); } catch (e) {}
111
131
  }
112
132
 
133
+ function isRecord(value) {
134
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
135
+ }
136
+
137
+ function hasAuthenticationInConfig(config) {
138
+ return Boolean(
139
+ (process.env.TRACKLY_API_KEY || '').trim()
140
+ || (isRecord(config) && (config.apiKey || config.token || config.refreshToken))
141
+ );
142
+ }
143
+
144
+ function configAfterCredentialRemoval(config, scope) {
145
+ const next = isRecord(config) ? { ...config } : {};
146
+ if (scope === 'all' || scope === 'apiKey') delete next.apiKey;
147
+ if (scope === 'all' || scope === 'oauth') {
148
+ delete next.token;
149
+ delete next.refreshToken;
150
+ }
151
+ return next;
152
+ }
153
+
154
+ async function resolveAnalyticsOptOutBeforeCredentialLoss(
155
+ request = apiRequest,
156
+ timeoutMs = ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS
157
+ ) {
158
+ const controller = new AbortController();
159
+ let timeout;
160
+ try {
161
+ const preference = await Promise.race([
162
+ Promise.resolve().then(() => request(
163
+ 'GET',
164
+ '/api/jobscout/analytics-preference',
165
+ null,
166
+ false,
167
+ true,
168
+ null,
169
+ null,
170
+ { signal: controller.signal, timeoutMs }
171
+ )),
172
+ new Promise((_, reject) => {
173
+ timeout = setTimeout(() => {
174
+ controller.abort();
175
+ reject(new Error('analytics_preference_sync_timeout'));
176
+ }, timeoutMs);
177
+ }),
178
+ ]);
179
+ if (typeof preference?.shareUsageAnalytics !== 'boolean') {
180
+ throw new Error('analytics_preference_invalid');
181
+ }
182
+ return preference.shareUsageAnalytics === false;
183
+ } catch {
184
+ // Fail closed: if the authoritative preference cannot be read before the
185
+ // final credential disappears, suppress future anonymous MCP capture.
186
+ return true;
187
+ } finally {
188
+ clearTimeout(timeout);
189
+ }
190
+ }
191
+
192
+ async function synchronizeAnalyticsPreferenceBeforeLogout(
193
+ request = apiRequest,
194
+ options = {}
195
+ ) {
196
+ const timeoutMs = options.timeoutMs ?? ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS;
197
+ try {
198
+ if (!hasAuth()) return;
199
+ } catch {
200
+ // A corrupt or unreadable config must never prevent logout. clearConfig()
201
+ // owns the best-effort removal path and will run immediately afterward.
202
+ return;
203
+ }
204
+ const optedOut = await resolveAnalyticsOptOutBeforeCredentialLoss(request, timeoutMs);
205
+ let config = {};
206
+ try {
207
+ const loaded = loadConfig();
208
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) config = loaded;
209
+ } catch {
210
+ // Environment-provided authentication can still make the preference
211
+ // request valid when the on-disk config is corrupt. Replace only the
212
+ // recoverable analytics marker and let clearConfig() remove credentials.
213
+ }
214
+ if (optedOut) config.mcpAnalyticsOptOut = true;
215
+ else delete config.mcpAnalyticsOptOut;
216
+ try {
217
+ saveConfig(config);
218
+ } catch {
219
+ // Analytics synchronization is best-effort and must never block logout.
220
+ }
221
+ }
222
+
223
+ async function removeCredentials({
224
+ scope = 'all',
225
+ request = apiRequest,
226
+ timeoutMs = ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS,
227
+ preferenceUnavailable = false,
228
+ } = {}) {
229
+ let config = {};
230
+ try {
231
+ const loaded = loadConfig();
232
+ if (isRecord(loaded)) config = loaded;
233
+ } catch {
234
+ // Corrupt or unreadable config still belongs to the best-effort removal
235
+ // path below. There is no safe credential state to preserve from it.
236
+ }
237
+
238
+ let next = configAfterCredentialRemoval(config, scope);
239
+ const removesEphemeralCredential = scope === 'all'
240
+ && Boolean((process.env.TRACKLY_API_KEY || '').trim());
241
+ const removesFinalCredential = removesEphemeralCredential
242
+ || (hasAuthenticationInConfig(config) && !hasAuthenticationInConfig(next));
243
+
244
+ if (removesFinalCredential) {
245
+ const optedOut = preferenceUnavailable
246
+ ? true
247
+ : await resolveAnalyticsOptOutBeforeCredentialLoss(request, timeoutMs);
248
+ if (optedOut) next.mcpAnalyticsOptOut = true;
249
+ else delete next.mcpAnalyticsOptOut;
250
+ }
251
+
252
+ if (scope === 'all') {
253
+ // Preserve logout's historical behavior of clearing endpoint overrides and
254
+ // all future config keys, with only the anonymous opt-out allowed through.
255
+ if (next.mcpAnalyticsOptOut === true) {
256
+ try {
257
+ saveConfig({ mcpAnalyticsOptOut: true });
258
+ return;
259
+ } catch {
260
+ // Credential removal wins over marker preservation on write failure.
261
+ }
262
+ }
263
+ const { file } = getConfigPaths();
264
+ try { fs.unlinkSync(file); } catch (_) {}
265
+ return;
266
+ }
267
+
268
+ if (Object.keys(next).length === 0) {
269
+ const { file } = getConfigPaths();
270
+ try { fs.unlinkSync(file); } catch (_) {}
271
+ return;
272
+ }
273
+ try {
274
+ saveConfig(next);
275
+ } catch (error) {
276
+ // If the surgical rewrite cannot be persisted, do not leave known-invalid
277
+ // credentials in the old file. Best-effort unlink is the safer fallback.
278
+ const { file } = getConfigPaths();
279
+ try { fs.unlinkSync(file); } catch (_) {}
280
+ throw error;
281
+ }
282
+ }
283
+
113
284
  // Surgical alternative to clearConfig() for the refresh-failure path. Deletes ONLY the
114
285
  // OAuth credentials (token + refreshToken) while preserving everything else on disk
115
286
  // (baseUrl, apiKey, timeout overrides, future keys). A refresh token being invalidated
@@ -488,10 +659,10 @@ async function _doRefresh() {
488
659
  // Treat as an invalidated session and purge ONLY the OAuth pair so we don't retry on
489
660
  // every subsequent 401. Without this, a backend that signals failure via JSON instead of
490
661
  // a 4xx status would leave the user stuck in a doomed-refresh loop forever.
491
- // Use clearOAuthTokens() — NOT clearConfig() — so the user's baseUrl, apiKey, and any
492
- // other persisted settings survive a token expiry. (Bug caught by Cursor Bugbot on
493
- // PR #20 post-review: the earlier clearConfig() call silently wiped baseUrl too.)
494
- try { clearOAuthTokens(); } catch (_) {}
662
+ // Use the centralized OAuth-only removal — NOT clearConfig() — so the user's
663
+ // baseUrl, apiKey, and other persisted settings survive a token expiry while
664
+ // final-credential loss still preserves the analytics preference safely.
665
+ try { await removeCredentials({ scope: 'oauth', preferenceUnavailable: true }); } catch (_) {}
495
666
  } catch (e) {
496
667
  if (isMaintenanceError(e)) {
497
668
  throw e;
@@ -503,7 +674,7 @@ async function _doRefresh() {
503
674
  // purge the token. Surgical OAuth-only clear preserves baseUrl + apiKey + timeout overrides.
504
675
  const status = e && typeof e.status === 'number' ? e.status : null;
505
676
  if (status === 400 || status === 401 || status === 403) {
506
- try { clearOAuthTokens(); } catch (_) {}
677
+ try { await removeCredentials({ scope: 'oauth', preferenceUnavailable: true }); } catch (_) {}
507
678
  }
508
679
  }
509
680
  return null;
@@ -516,7 +687,8 @@ function apiRequest(
516
687
  skipAuth = false,
517
688
  _isRetry = false,
518
689
  userAgent = null,
519
- additionalHeaders = null
690
+ additionalHeaders = null,
691
+ requestOptions = null
520
692
  ) {
521
693
  return new Promise((resolve, reject) => {
522
694
  let url;
@@ -552,7 +724,15 @@ function apiRequest(
552
724
  headers['Idempotency-Key'] = idempotencyEntry[1];
553
725
  }
554
726
  const authMethod = getAuthMethod();
555
- const timeoutMs = getRequestTimeoutMs();
727
+ const requestedTimeoutMs = Number(requestOptions?.timeoutMs);
728
+ const timeoutMs = Number.isFinite(requestedTimeoutMs) && requestedTimeoutMs > 0
729
+ ? Math.min(getRequestTimeoutMs(), requestedTimeoutMs)
730
+ : getRequestTimeoutMs();
731
+ const signal = requestOptions?.signal;
732
+ if (signal?.aborted) {
733
+ reject(new Error('Trackly request aborted.'));
734
+ return;
735
+ }
556
736
  const hasSensitivePayload = Boolean(data && typeof data === 'object' && 'refreshToken' in data);
557
737
 
558
738
  try {
@@ -640,7 +820,8 @@ function apiRequest(
640
820
  false,
641
821
  true,
642
822
  userAgent,
643
- additionalHeaders
823
+ additionalHeaders,
824
+ requestOptions
644
825
  );
645
826
  return resolve(retried);
646
827
  } catch (e) { return reject(e); }
@@ -671,6 +852,9 @@ function apiRequest(
671
852
  req.on('timeout', () => {
672
853
  req.destroy(new Error(`Trackly request timed out after ${timeoutMs}ms`));
673
854
  });
855
+ const abortRequest = () => req.destroy(new Error('Trackly request aborted.'));
856
+ signal?.addEventListener('abort', abortRequest, { once: true });
857
+ req.on('close', () => signal?.removeEventListener('abort', abortRequest));
674
858
  req.on('error', reject);
675
859
  if (data) req.write(JSON.stringify(data));
676
860
  req.end();
@@ -827,7 +1011,9 @@ module.exports = {
827
1011
  normalizeMaintenance,
828
1012
  normalizeEndpoint,
829
1013
  refreshAccessToken,
1014
+ removeCredentials,
830
1015
  saveConfig,
1016
+ synchronizeAnalyticsPreferenceBeforeLogout,
831
1017
  CONFIG_DIR: DEFAULT_CONFIG_DIR,
832
1018
  CONFIG_FILE: path.join(DEFAULT_CONFIG_DIR, 'config.json'),
833
1019
  };