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 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 (10 tools) | -- | -- |
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 10 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
+ 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
- config.baseUrl = normalizeBaseUrlValue(args['base-url']);
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
- process.env.TRACKLY_BASE_URL = normalizeBaseUrlValue(args['base-url']);
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,
@@ -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/hybrid/onsite filtering, use the `remote` boolean or `locationFilter: "remote"`; hybrid and onsite are not exposed as filters.
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
- if (fs.existsSync(file)) {
34
- return JSON.parse(fs.readFileSync(file, 'utf8'));
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
- async function refreshAccessToken() {
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 { json = body ? JSON.parse(body) : {}; } catch { json = { raw: body }; }
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.2",
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.3.2",
20
+ "version": "0.4.0",
21
21
  "packages": [
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "trackly-cli",
25
- "version": "0.3.2",
25
+ "version": "0.4.0",
26
26
  "runtimeHint": "npx",
27
27
  "runtimeArguments": [
28
28
  {