trackly-cli 0.2.8 → 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
@@ -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 | 10 MCP tools
38
+ 1,900+ companies | 128K+ jobs | 40+ ATS types | CLI + MCP | 11 MCP tools
39
39
 
40
40
  ## CLI Commands
41
41
 
@@ -63,6 +63,7 @@ trackly referral start 1234 # Start a referral campaign
63
63
  trackly referral status 1234 # Check referral campaign status
64
64
  trackly company-brief 243 # Get company brief (--refresh to regenerate)
65
65
  trackly company-workspace 243 # Full company workspace view
66
+ trackly request-company "eBay" # Request a company be added (--url, --notes optional)
66
67
  trackly api-key create # Generate API key
67
68
  trackly api-key list # List API keys
68
69
  trackly config # Show current CLI config
@@ -150,6 +151,7 @@ Then use natural language in any of these clients:
150
151
  | trackly_get_job_brief | Get network brief for a job (company signal, top contact, actions) |
151
152
  | trackly_contacts_at_company | Search contacts at a specific company |
152
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) |
153
155
 
154
156
  ## Authentication
155
157
 
@@ -209,7 +211,7 @@ trackly config --base-url http://127.0.0.1:3000 # Point at a different backend
209
211
  | Job search + filters | Yes | Yes | Yes |
210
212
  | Apply/save/dismiss | Yes | Yes | Yes |
211
213
  | AI-powered search | Yes (trackly ask) | Yes | Yes |
212
- | MCP integration | Yes (10 tools) | -- | -- |
214
+ | MCP integration | Yes (11 tools) | -- | -- |
213
215
  | Browser required | No | Yes | No |
214
216
  | Best for | Terminal + AI agents | Visual browsing | Custom integrations |
215
217
 
@@ -223,7 +225,7 @@ Install trackly-cli (`npm install -g trackly-cli`), authenticate with `trackly l
223
225
 
224
226
  **What MCP servers exist for job searching?**
225
227
 
226
- 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.
227
229
 
228
230
  **How do I use Claude Code for job hunting?**
229
231
 
package/bin/trackly CHANGED
@@ -23,17 +23,56 @@ 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',
29
30
  'limit',
30
31
  'name',
32
+ 'notes',
31
33
  'offset',
32
34
  'region',
33
35
  'search',
34
36
  'sort',
35
37
  'status',
38
+ 'url',
36
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
+ };
37
76
  const AUTH_HELP_TEXT = `Not authenticated.
38
77
  Option 1: trackly login (Google OAuth)
39
78
  Option 2: trackly config --api-key trk_xxx (get key at usetrackly.app)
@@ -104,6 +143,59 @@ function parseArgs(args) {
104
143
  return result;
105
144
  }
106
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
+
107
199
  function failNow(message, exitCode = 1) {
108
200
  outputError(message);
109
201
  process.exit(exitCode);
@@ -668,6 +760,43 @@ async function cmdCompanyWorkspace(args) {
668
760
  }
669
761
  }
670
762
 
763
+ async function cmdRequestCompany(args) {
764
+ // Positional arg = company name. Allow multi-word names without forcing the user
765
+ // to quote, e.g. `trackly request-company Built Robotics --url ...` joins the
766
+ // free-floating positionals into "Built Robotics". Matches `trackly ask` UX.
767
+ const name = (args._ || []).join(' ').trim();
768
+ if (!name) {
769
+ failNow('Company name required. Usage: trackly request-company <name> [--url <url>] [--notes <notes>]');
770
+ }
771
+ if (!hasAuth()) failNow(AUTH_HELP_TEXT);
772
+
773
+ const url = typeof args.url === 'string' ? args.url : '';
774
+ const notes = typeof args.notes === 'string' ? args.notes : '';
775
+
776
+ try {
777
+ const result = await apiRequest('POST', '/api/jobscout/companies/request', {
778
+ company_name: name,
779
+ company_url: url,
780
+ notes: notes,
781
+ source: 'cli',
782
+ });
783
+ if (isJSON()) return printJson(result);
784
+ const requestId = result.requestId || result.id || '?';
785
+ console.log(color('green', `Requested "${name}" (#${requestId}). Trackly team will review shortly.`));
786
+ } catch (e) {
787
+ // Backend returns 429 (rate limit, max 5 pending) and 409 (duplicate pending request)
788
+ // with a specific `error` message. Surface that message verbatim so the user knows
789
+ // exactly which gate fired without having to enable --json.
790
+ if (e.status === 429) {
791
+ setErrorExit(e.error || 'Maximum 5 pending requests allowed. Please wait for existing requests to be reviewed.');
792
+ } else if (e.status === 409) {
793
+ setErrorExit(e.error || 'You already have a pending request for this company.');
794
+ } else {
795
+ setErrorExit(e.error || e.message || 'Failed to request company');
796
+ }
797
+ }
798
+ }
799
+
671
800
  async function cmdConfig(args) {
672
801
  if (args['api-key'] === null) failNow('API key value required. Usage: trackly config --api-key <key>');
673
802
  if (args['base-url'] === null) failNow('Base URL value required. Usage: trackly config --base-url <url>');
@@ -708,7 +837,11 @@ async function cmdConfig(args) {
708
837
  }
709
838
 
710
839
  if (args['base-url'] !== undefined) {
711
- 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
+ }
712
845
  updated = true;
713
846
  console.log(color('green', `Base URL set to ${config.baseUrl}.`));
714
847
  }
@@ -770,6 +903,7 @@ ${color('bold', 'Commands:')}
770
903
  ${color('cyan', 'brief <id>')} Get network brief for a job
771
904
  ${color('cyan', 'company-brief <id>')} Get company brief (--refresh to generate)
772
905
  ${color('cyan', 'company-workspace <id>')} Show company workspace (jobs, contacts, campaigns)
906
+ ${color('cyan', 'request-company <name>')} Request a company be added to Trackly (--url, --notes)
773
907
  ${color('cyan', 'api-key create')} Generate an API key
774
908
  ${color('cyan', 'api-key list')} List your API keys
775
909
  ${color('cyan', 'config')} Show or update CLI config (--api-key, --base-url)
@@ -842,7 +976,11 @@ async function main() {
842
976
  }
843
977
 
844
978
  if (args.command !== 'config' && args['base-url']) {
845
- 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
+ }
846
984
  }
847
985
 
848
986
  if (args.version || args.v || args.command === 'version') {
@@ -855,6 +993,10 @@ async function main() {
855
993
  process.exit(0);
856
994
  }
857
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
+
858
1000
  switch (args.command) {
859
1001
  case 'login':
860
1002
  await cmdLogin();
@@ -926,6 +1068,9 @@ async function main() {
926
1068
  case 'company-workspace':
927
1069
  await cmdCompanyWorkspace(args);
928
1070
  break;
1071
+ case 'request-company':
1072
+ await cmdRequestCompany(args);
1073
+ break;
929
1074
  case 'api-key':
930
1075
  await cmdApiKey(args);
931
1076
  break;
@@ -950,9 +1095,13 @@ if (require.main === module) {
950
1095
  }
951
1096
 
952
1097
  module.exports = {
1098
+ COMMAND_FLAGS,
1099
+ UNIVERSAL_FLAGS,
953
1100
  getPublicConfig,
954
1101
  hasJobListFilters,
1102
+ levenshtein,
955
1103
  main,
1104
+ nearestFlag,
956
1105
  normalizeBaseUrlValue,
957
1106
  openExternalUrl,
958
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/mcp/server.js CHANGED
@@ -297,6 +297,24 @@ function createServer() {
297
297
  }, 'Failed to fetch company workspace')
298
298
  );
299
299
 
300
+ server.tool(
301
+ 'trackly_request_company',
302
+ 'Request that a company be added to Trackly\'s tracked companies. Use when the user asks about a company that isn\'t in trackly_search_companies / trackly_list_companies results. Rate-limited to 5 pending requests per user.',
303
+ {
304
+ companyName: z.string().min(1).max(200).describe('Company name (e.g. "eBay")'),
305
+ companyUrl: z.string().max(500).optional().describe('Optional careers page or homepage URL (e.g. "https://careers.ebay.com")'),
306
+ notes: z.string().max(1000).optional().describe('Optional context (e.g. "MBA hiring page", "specific role I want tracked")'),
307
+ },
308
+ wrapTool(async ({ companyName, companyUrl, notes }) => {
309
+ return apiRequest('POST', '/api/jobscout/companies/request', {
310
+ company_name: companyName,
311
+ company_url: companyUrl || '',
312
+ notes: notes || '',
313
+ source: 'mcp',
314
+ }, false, false, MCP_USER_AGENT);
315
+ }, 'Failed to request company')
316
+ );
317
+
300
318
  return server;
301
319
  }
302
320
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.2.8",
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.2.8",
20
+ "version": "0.4.0",
21
21
  "packages": [
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "trackly-cli",
25
- "version": "0.2.8",
25
+ "version": "0.4.0",
26
26
  "runtimeHint": "npx",
27
27
  "runtimeArguments": [
28
28
  {