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 +17 -5
- package/bin/trackly +10 -6
- package/docs/trackly-tools.md +1 -0
- package/lib/client.js +194 -8
- package/mcp/analytics.js +903 -0
- package/mcp/apply-tools.js +31 -10
- package/mcp/server.js +123 -8
- package/npm-shrinkwrap.json +56 -3
- package/package.json +4 -2
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[](https://www.npmjs.com/package/trackly-cli)
|
|
2
2
|
[](https://opensource.org/licenses/MIT)
|
|
3
|
-
[](https://nodejs.org/)
|
|
4
4
|
[](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 |
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1058
|
+
let config = loadConfig();
|
|
1059
1059
|
let updated = false;
|
|
1060
1060
|
|
|
1061
1061
|
if (args['clear-api-key']) {
|
|
1062
|
-
|
|
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
|
-
|
|
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,
|
package/docs/trackly-tools.md
CHANGED
|
@@ -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
|
|
492
|
-
// other persisted settings survive a token expiry
|
|
493
|
-
//
|
|
494
|
-
try {
|
|
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 {
|
|
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
|
|
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
|
};
|