trackly-cli 0.3.2 → 0.4.0
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 +3 -2
- package/bin/trackly +108 -2
- package/docs/trackly-tools.md +2 -1
- package/lib/client.js +55 -5
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -151,6 +151,7 @@ Then use natural language in any of these clients:
|
|
|
151
151
|
| trackly_get_job_brief | Get network brief for a job (company signal, top contact, actions) |
|
|
152
152
|
| trackly_contacts_at_company | Search contacts at a specific company |
|
|
153
153
|
| trackly_get_company_workspace | Full company workspace (jobs, contacts, hiring managers, campaigns) |
|
|
154
|
+
| trackly_request_company | Request a company be added to Trackly (rate-limited to 5 pending) |
|
|
154
155
|
|
|
155
156
|
## Authentication
|
|
156
157
|
|
|
@@ -210,7 +211,7 @@ trackly config --base-url http://127.0.0.1:3000 # Point at a different backend
|
|
|
210
211
|
| Job search + filters | Yes | Yes | Yes |
|
|
211
212
|
| Apply/save/dismiss | Yes | Yes | Yes |
|
|
212
213
|
| AI-powered search | Yes (trackly ask) | Yes | Yes |
|
|
213
|
-
| MCP integration | Yes (
|
|
214
|
+
| MCP integration | Yes (11 tools) | -- | -- |
|
|
214
215
|
| Browser required | No | Yes | No |
|
|
215
216
|
| Best for | Terminal + AI agents | Visual browsing | Custom integrations |
|
|
216
217
|
|
|
@@ -224,7 +225,7 @@ Install trackly-cli (`npm install -g trackly-cli`), authenticate with `trackly l
|
|
|
224
225
|
|
|
225
226
|
**What MCP servers exist for job searching?**
|
|
226
227
|
|
|
227
|
-
trackly-cli includes a built-in MCP server with
|
|
228
|
+
trackly-cli includes a built-in MCP server with 11 tools for job search, company lookup, and application tracking. Run `trackly mcp` or add it to Claude Code with `claude mcp add --scope user trackly -- trackly mcp`. It connects to a live database of 128,000+ jobs across 1,900+ companies.
|
|
228
229
|
|
|
229
230
|
**How do I use Claude Code for job hunting?**
|
|
230
231
|
|
package/bin/trackly
CHANGED
|
@@ -23,6 +23,7 @@ const BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'version', 'v', 'clear-api-k
|
|
|
23
23
|
const VALUE_FLAGS = new Set([
|
|
24
24
|
'api-key',
|
|
25
25
|
'base-url',
|
|
26
|
+
'company',
|
|
26
27
|
'function',
|
|
27
28
|
'job-type',
|
|
28
29
|
'keywords',
|
|
@@ -36,6 +37,42 @@ const VALUE_FLAGS = new Set([
|
|
|
36
37
|
'status',
|
|
37
38
|
'url',
|
|
38
39
|
]);
|
|
40
|
+
|
|
41
|
+
// Flags accepted on ANY command. `api-key`/`base-url` are global overrides consumed
|
|
42
|
+
// in main() before dispatch; help/version are handled by early-returns.
|
|
43
|
+
const UNIVERSAL_FLAGS = new Set(['json', 'help', 'h', 'version', 'v', 'api-key', 'base-url']);
|
|
44
|
+
|
|
45
|
+
// Per-command allow-lists — each set is the flags that command's handler actually
|
|
46
|
+
// reads. Keyed at the command level (the union across a command's subcommands) so a
|
|
47
|
+
// valid filtered detail call like `jobs 123 --region us` is never falsely rejected,
|
|
48
|
+
// while typos (`--regoin`) and wrong-command flags (`jobs --url`, `jobs --search`)
|
|
49
|
+
// still are. `jobs` includes the deprecated `location`/`modality` so they reach the
|
|
50
|
+
// richer DEPRECATED_JOB_FLAGS migration message in cmdJobs rather than a generic error.
|
|
51
|
+
// Commands NOT listed here (e.g. `mcp`, or an unknown command) skip flag validation —
|
|
52
|
+
// the dispatcher reports unknown commands itself.
|
|
53
|
+
const COMMAND_FLAGS = {
|
|
54
|
+
login: [],
|
|
55
|
+
logout: [],
|
|
56
|
+
whoami: [],
|
|
57
|
+
jobs: ['function', 'company', 'region', 'job-type', 'remote', 'status', 'sort', 'limit', 'offset', 'keywords', 'location', 'modality'],
|
|
58
|
+
job: [],
|
|
59
|
+
companies: ['limit', 'offset'],
|
|
60
|
+
search: [],
|
|
61
|
+
stats: [],
|
|
62
|
+
status: [],
|
|
63
|
+
apply: [],
|
|
64
|
+
save: [],
|
|
65
|
+
dismiss: [],
|
|
66
|
+
ask: [],
|
|
67
|
+
contacts: ['search', 'limit', 'offset'],
|
|
68
|
+
referral: [],
|
|
69
|
+
brief: [],
|
|
70
|
+
'company-brief': ['refresh'],
|
|
71
|
+
'company-workspace': [],
|
|
72
|
+
'request-company': ['url', 'notes'],
|
|
73
|
+
'api-key': ['name'],
|
|
74
|
+
config: ['api-key', 'base-url', 'clear-api-key', 'clear-base-url'],
|
|
75
|
+
};
|
|
39
76
|
const AUTH_HELP_TEXT = `Not authenticated.
|
|
40
77
|
Option 1: trackly login (Google OAuth)
|
|
41
78
|
Option 2: trackly config --api-key trk_xxx (get key at usetrackly.app)
|
|
@@ -106,6 +143,59 @@ function parseArgs(args) {
|
|
|
106
143
|
return result;
|
|
107
144
|
}
|
|
108
145
|
|
|
146
|
+
// Tiny Levenshtein (no dep) for "did you mean" flag suggestions.
|
|
147
|
+
function levenshtein(a, b) {
|
|
148
|
+
const m = a.length;
|
|
149
|
+
const n = b.length;
|
|
150
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
151
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
152
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
153
|
+
for (let i = 1; i <= m; i++) {
|
|
154
|
+
for (let j = 1; j <= n; j++) {
|
|
155
|
+
dp[i][j] = Math.min(
|
|
156
|
+
dp[i - 1][j] + 1,
|
|
157
|
+
dp[i][j - 1] + 1,
|
|
158
|
+
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return dp[m][n];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function nearestFlag(key, allowed) {
|
|
166
|
+
let best = null;
|
|
167
|
+
let bestDistance = Infinity;
|
|
168
|
+
for (const flag of allowed) {
|
|
169
|
+
const d = levenshtein(key, flag);
|
|
170
|
+
if (d < bestDistance) {
|
|
171
|
+
bestDistance = d;
|
|
172
|
+
best = flag;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return bestDistance <= 2 ? best : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Reject flags that the resolved command doesn't accept. Catches typos
|
|
179
|
+
// (`--regoin`) and wrong-command flags (`jobs --url`) that parseArgs would
|
|
180
|
+
// otherwise capture and the handler would silently ignore — returning, e.g., an
|
|
181
|
+
// unfiltered job feed that looks filtered. Unknown commands (and `mcp`) skip this;
|
|
182
|
+
// the dispatcher reports unknown commands itself.
|
|
183
|
+
function validateFlags(args) {
|
|
184
|
+
const cmd = args.command;
|
|
185
|
+
if (!cmd || !Object.prototype.hasOwnProperty.call(COMMAND_FLAGS, cmd)) return;
|
|
186
|
+
const allowed = new Set([...UNIVERSAL_FLAGS, ...COMMAND_FLAGS[cmd]]);
|
|
187
|
+
for (const key of Object.keys(args)) {
|
|
188
|
+
if (key === '_' || key === 'command') continue;
|
|
189
|
+
if (allowed.has(key)) continue;
|
|
190
|
+
// Strip non-printable chars so a crafted `--$'\x1b[…'` flag can't inject
|
|
191
|
+
// terminal escape sequences into the error message.
|
|
192
|
+
const safe = String(key).replace(/[^\x20-\x7e]/g, '');
|
|
193
|
+
const suggestion = nearestFlag(key, allowed);
|
|
194
|
+
const hint = suggestion ? ` Did you mean --${suggestion}?` : '';
|
|
195
|
+
failNow(`Unknown option --${safe} for 'trackly ${cmd}'.${hint} Run \`trackly --help\` for valid options.`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
109
199
|
function failNow(message, exitCode = 1) {
|
|
110
200
|
outputError(message);
|
|
111
201
|
process.exit(exitCode);
|
|
@@ -747,7 +837,11 @@ async function cmdConfig(args) {
|
|
|
747
837
|
}
|
|
748
838
|
|
|
749
839
|
if (args['base-url'] !== undefined) {
|
|
750
|
-
|
|
840
|
+
try {
|
|
841
|
+
config.baseUrl = normalizeBaseUrlValue(args['base-url']);
|
|
842
|
+
} catch (e) {
|
|
843
|
+
failNow('Invalid base URL: ' + e.message);
|
|
844
|
+
}
|
|
751
845
|
updated = true;
|
|
752
846
|
console.log(color('green', `Base URL set to ${config.baseUrl}.`));
|
|
753
847
|
}
|
|
@@ -882,7 +976,11 @@ async function main() {
|
|
|
882
976
|
}
|
|
883
977
|
|
|
884
978
|
if (args.command !== 'config' && args['base-url']) {
|
|
885
|
-
|
|
979
|
+
try {
|
|
980
|
+
process.env.TRACKLY_BASE_URL = normalizeBaseUrlValue(args['base-url']);
|
|
981
|
+
} catch (e) {
|
|
982
|
+
failNow('Invalid base URL: ' + e.message);
|
|
983
|
+
}
|
|
886
984
|
}
|
|
887
985
|
|
|
888
986
|
if (args.version || args.v || args.command === 'version') {
|
|
@@ -895,6 +993,10 @@ async function main() {
|
|
|
895
993
|
process.exit(0);
|
|
896
994
|
}
|
|
897
995
|
|
|
996
|
+
// Reject unknown / wrong-command flags AFTER the help+version early-returns
|
|
997
|
+
// (so `trackly --help --weird` still shows help instead of erroring).
|
|
998
|
+
validateFlags(args);
|
|
999
|
+
|
|
898
1000
|
switch (args.command) {
|
|
899
1001
|
case 'login':
|
|
900
1002
|
await cmdLogin();
|
|
@@ -993,9 +1095,13 @@ if (require.main === module) {
|
|
|
993
1095
|
}
|
|
994
1096
|
|
|
995
1097
|
module.exports = {
|
|
1098
|
+
COMMAND_FLAGS,
|
|
1099
|
+
UNIVERSAL_FLAGS,
|
|
996
1100
|
getPublicConfig,
|
|
997
1101
|
hasJobListFilters,
|
|
1102
|
+
levenshtein,
|
|
998
1103
|
main,
|
|
1104
|
+
nearestFlag,
|
|
999
1105
|
normalizeBaseUrlValue,
|
|
1000
1106
|
openExternalUrl,
|
|
1001
1107
|
parseArgs,
|
package/docs/trackly-tools.md
CHANGED
|
@@ -72,7 +72,7 @@ You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
|
|
|
72
72
|
- `locationFilter`: pass ONE of
|
|
73
73
|
- a single scalar: `us`, `non_us`, `all`, or a region tag (`europe`, `latam`, `middle_east`, `asia`, `africa`, `canada`, `oceania`, `remote`, `unknown`), OR
|
|
74
74
|
- an array of region tags for multi-region (e.g. `["europe", "canada"]`). The array form excludes `us` — combining `us` with other tags in an array causes the backend to silently drop the others. To get "not US" use the scalar `non_us` alone.
|
|
75
|
-
- `jobModality`: `full_time`, `internship`, `all` — employment type, NOT work-location style. For remote
|
|
75
|
+
- `jobModality`: `full_time`, `internship`, `all` — employment type, NOT work-location style. For remote filtering, use the `remote` boolean or `locationFilter: "remote"`. Hybrid and onsite are not exposed as filters.
|
|
76
76
|
- `remote` (boolean): filter to remote jobs only. Maps to `usStates=REMOTE`.
|
|
77
77
|
- `status`: your application pipeline state. Values: `new`, `applying`, `applied_confirmed`, `check_later`, `not_interested`, `all`.
|
|
78
78
|
- `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.
|
|
@@ -86,6 +86,7 @@ You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
|
|
|
86
86
|
- **trackly_get_job_brief** — Get network brief for a job (company signal, top contact, actions)
|
|
87
87
|
- **trackly_contacts_at_company** — Search contacts at a specific company
|
|
88
88
|
- **trackly_get_company_workspace** — Get full company workspace (jobs, contacts, hiring managers, campaigns)
|
|
89
|
+
- **trackly_request_company** — Request that a company be added to Trackly's tracked companies. Rate-limited to 5 pending requests per user. Parameters: `companyName` (required), `companyUrl` (optional), `notes` (optional).
|
|
89
90
|
|
|
90
91
|
### Example Prompts
|
|
91
92
|
|
package/lib/client.js
CHANGED
|
@@ -29,17 +29,28 @@ function getConfigPaths() {
|
|
|
29
29
|
|
|
30
30
|
function loadConfig() {
|
|
31
31
|
const { file } = getConfigPaths();
|
|
32
|
+
// Read the file directly rather than gating on fs.existsSync(): existsSync can
|
|
33
|
+
// mask a permission error (and races the read). Distinguish "no config yet"
|
|
34
|
+
// (ENOENT → {}) from "config exists but unreadable" (EACCES/EPERM → a clear,
|
|
35
|
+
// actionable error) from "config is corrupt JSON".
|
|
36
|
+
let raw;
|
|
32
37
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error && error.code === 'ENOENT') return {};
|
|
41
|
+
if (error && (error.code === 'EACCES' || error.code === 'EPERM')) {
|
|
42
|
+
throw new TracklyConfigError(`Trackly config is not readable (${file}). Check file permissions.`);
|
|
35
43
|
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(raw);
|
|
36
48
|
} catch (error) {
|
|
37
49
|
if (error instanceof SyntaxError) {
|
|
38
50
|
throw new TracklyConfigError(`Trackly config is invalid JSON. Fix or delete ${file}.`);
|
|
39
51
|
}
|
|
40
52
|
throw error;
|
|
41
53
|
}
|
|
42
|
-
return {};
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
function ensureConfigDir() {
|
|
@@ -190,7 +201,28 @@ function getRequestHeaders(skipAuth = false, userAgent = null) {
|
|
|
190
201
|
return headers;
|
|
191
202
|
}
|
|
192
203
|
|
|
193
|
-
|
|
204
|
+
// Single-flight wrapper around the refresh logic. Coalesces concurrent refreshes
|
|
205
|
+
// WITHIN THIS PROCESS so simultaneous 401s (e.g. parallel MCP tool calls in the
|
|
206
|
+
// long-lived stdio server) don't each fire `POST /api/auth/refresh`. The backend
|
|
207
|
+
// rotates the refresh token on use, so a second in-flight refresh would send a
|
|
208
|
+
// now-stale token → 4xx → clearOAuthTokens() → the user is silently logged out
|
|
209
|
+
// mid-session even though the first refresh succeeded. All concurrent callers
|
|
210
|
+
// await the same promise, then each retries reading the freshly-saved token.
|
|
211
|
+
//
|
|
212
|
+
// Scope: this does NOT coordinate across multiple `trackly` processes sharing
|
|
213
|
+
// ~/.trackly/config.json. That is acceptable — saveConfig() is atomic (renameSync)
|
|
214
|
+
// and concurrent multi-process refresh is rare; a cross-process file lock is a
|
|
215
|
+
// possible future addition, not in scope here.
|
|
216
|
+
let _refreshInFlight = null;
|
|
217
|
+
function refreshAccessToken() {
|
|
218
|
+
if (_refreshInFlight) return _refreshInFlight;
|
|
219
|
+
// _doRefresh resolves to a token-or-null and never rejects, so the cached
|
|
220
|
+
// promise can be safely shared and the finally() latch always clears.
|
|
221
|
+
_refreshInFlight = _doRefresh().finally(() => { _refreshInFlight = null; });
|
|
222
|
+
return _refreshInFlight;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function _doRefresh() {
|
|
194
226
|
const rt = getRefreshToken();
|
|
195
227
|
if (!rt) return null;
|
|
196
228
|
|
|
@@ -291,7 +323,25 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
|
|
|
291
323
|
if (aborted) return; // req.destroy already rejected via 'error' handler
|
|
292
324
|
|
|
293
325
|
let json;
|
|
294
|
-
try {
|
|
326
|
+
try {
|
|
327
|
+
json = body ? JSON.parse(body) : {};
|
|
328
|
+
} catch {
|
|
329
|
+
// Non-JSON body (HTML error page, plaintext, proxy notice, …). Keep the
|
|
330
|
+
// raw body for debugging but always attach a usable `message` so the
|
|
331
|
+
// downstream `e.error || e.message || fallback` chain has real context.
|
|
332
|
+
json = { raw: body, message: 'Received a non-JSON response from the Trackly API.' };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// The Trackly API is a same-origin JSON API that should never redirect on
|
|
336
|
+
// the paths the CLI/MCP call. node:http does not auto-follow 3xx, so a
|
|
337
|
+
// redirect would otherwise resolve as "success" with the redirect headers
|
|
338
|
+
// as bogus data. Treat it as an error with a clear, actionable message.
|
|
339
|
+
if (res.statusCode >= 300 && res.statusCode < 400) {
|
|
340
|
+
return reject({
|
|
341
|
+
status: res.statusCode,
|
|
342
|
+
message: `Unexpected redirect (HTTP ${res.statusCode}) from the Trackly API.`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
295
345
|
|
|
296
346
|
if (res.statusCode === 401 && !skipAuth && authMethod === 'token' && !_isRetry) {
|
|
297
347
|
// Try token refresh (once only)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackly-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.4.0",
|
|
21
21
|
"packages": [
|
|
22
22
|
{
|
|
23
23
|
"registryType": "npm",
|
|
24
24
|
"identifier": "trackly-cli",
|
|
25
|
-
"version": "0.
|
|
25
|
+
"version": "0.4.0",
|
|
26
26
|
"runtimeHint": "npx",
|
|
27
27
|
"runtimeArguments": [
|
|
28
28
|
{
|