trackly-cli 0.3.2 → 0.4.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 +3 -2
- package/bin/trackly +146 -21
- package/docs/trackly-tools.md +2 -1
- package/lib/client.js +55 -5
- package/lib/formatters.js +16 -3
- 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);
|
|
@@ -205,13 +295,16 @@ async function cmdLogin() {
|
|
|
205
295
|
|
|
206
296
|
console.log(color('cyan', 'Opening browser for Google login...'));
|
|
207
297
|
|
|
208
|
-
|
|
298
|
+
let activeServer = null;
|
|
299
|
+
|
|
300
|
+
function tryLogin() {
|
|
209
301
|
return new Promise((resolve, reject) => {
|
|
210
302
|
let settled = false;
|
|
211
303
|
let timeoutId = null;
|
|
212
304
|
|
|
213
305
|
const server = http.createServer((req, res) => {
|
|
214
|
-
|
|
306
|
+
// Base is only used to parse req.url's path + query; the port is irrelevant.
|
|
307
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
215
308
|
if (url.pathname === '/callback') {
|
|
216
309
|
const returnedState = url.searchParams.get('state');
|
|
217
310
|
if (returnedState !== state) {
|
|
@@ -297,15 +390,20 @@ p{color:#a1a1aa;font-size:15px;line-height:1.5}
|
|
|
297
390
|
}
|
|
298
391
|
};
|
|
299
392
|
|
|
393
|
+
activeServer = server;
|
|
394
|
+
|
|
300
395
|
server.on('error', (err) => {
|
|
301
|
-
|
|
302
|
-
reject(err);
|
|
303
|
-
} else {
|
|
304
|
-
settle(err);
|
|
305
|
-
}
|
|
396
|
+
settle(err);
|
|
306
397
|
});
|
|
307
398
|
|
|
308
|
-
|
|
399
|
+
// Port 0 → OS-assigned ephemeral port, always free (no fixed-range collision,
|
|
400
|
+
// so no retry loop needed). Verify it lands in the backend's accepted range.
|
|
401
|
+
server.listen(0, '127.0.0.1', () => {
|
|
402
|
+
const port = server.address().port;
|
|
403
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
|
|
404
|
+
settle(new Error(`OS assigned an out-of-range callback port (${port}); expected 1024-65535.`));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
309
407
|
console.log(color('dim', `Callback server on port ${port}`));
|
|
310
408
|
const loginUrl = `${baseUrl}/auth/google/cli?port=${port}&state=${state}`;
|
|
311
409
|
if (!openExternalUrl(loginUrl)) {
|
|
@@ -319,18 +417,20 @@ p{color:#a1a1aa;font-size:15px;line-height:1.5}
|
|
|
319
417
|
});
|
|
320
418
|
}
|
|
321
419
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (err.code === 'EADDRINUSE' && attempt < MAX_PORT_RETRIES - 1) {
|
|
329
|
-
console.log(color('dim', `Port ${port} in use, trying another...`));
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
throw err;
|
|
420
|
+
// One SIGINT handler for the whole login (not per-attempt): close the active
|
|
421
|
+
// callback server and exit cleanly on Ctrl-C, then always remove the handler so
|
|
422
|
+
// it can't leak. (No EADDRINUSE retry path now, so no handler-leak edge case.)
|
|
423
|
+
const onSigint = () => {
|
|
424
|
+
if (activeServer) {
|
|
425
|
+
try { activeServer.close(); } catch (_) {}
|
|
333
426
|
}
|
|
427
|
+
process.exit(130);
|
|
428
|
+
};
|
|
429
|
+
process.on('SIGINT', onSigint);
|
|
430
|
+
try {
|
|
431
|
+
await tryLogin();
|
|
432
|
+
} finally {
|
|
433
|
+
process.removeListener('SIGINT', onSigint);
|
|
334
434
|
}
|
|
335
435
|
}
|
|
336
436
|
|
|
@@ -711,6 +811,15 @@ async function cmdConfig(args) {
|
|
|
711
811
|
if (args['api-key'] === null) failNow('API key value required. Usage: trackly config --api-key <key>');
|
|
712
812
|
if (args['base-url'] === null) failNow('Base URL value required. Usage: trackly config --base-url <url>');
|
|
713
813
|
|
|
814
|
+
// Reject contradictory set+clear in the same invocation rather than silently
|
|
815
|
+
// letting last-write-win produce surprising results.
|
|
816
|
+
if (args['api-key'] !== undefined && args['clear-api-key']) {
|
|
817
|
+
failNow('Cannot use --api-key and --clear-api-key together.');
|
|
818
|
+
}
|
|
819
|
+
if (args['base-url'] !== undefined && args['clear-base-url']) {
|
|
820
|
+
failNow('Cannot use --base-url and --clear-base-url together.');
|
|
821
|
+
}
|
|
822
|
+
|
|
714
823
|
const config = loadConfig();
|
|
715
824
|
let updated = false;
|
|
716
825
|
|
|
@@ -747,7 +856,11 @@ async function cmdConfig(args) {
|
|
|
747
856
|
}
|
|
748
857
|
|
|
749
858
|
if (args['base-url'] !== undefined) {
|
|
750
|
-
|
|
859
|
+
try {
|
|
860
|
+
config.baseUrl = normalizeBaseUrlValue(args['base-url']);
|
|
861
|
+
} catch (e) {
|
|
862
|
+
failNow('Invalid base URL: ' + e.message);
|
|
863
|
+
}
|
|
751
864
|
updated = true;
|
|
752
865
|
console.log(color('green', `Base URL set to ${config.baseUrl}.`));
|
|
753
866
|
}
|
|
@@ -882,7 +995,11 @@ async function main() {
|
|
|
882
995
|
}
|
|
883
996
|
|
|
884
997
|
if (args.command !== 'config' && args['base-url']) {
|
|
885
|
-
|
|
998
|
+
try {
|
|
999
|
+
process.env.TRACKLY_BASE_URL = normalizeBaseUrlValue(args['base-url']);
|
|
1000
|
+
} catch (e) {
|
|
1001
|
+
failNow('Invalid base URL: ' + e.message);
|
|
1002
|
+
}
|
|
886
1003
|
}
|
|
887
1004
|
|
|
888
1005
|
if (args.version || args.v || args.command === 'version') {
|
|
@@ -895,6 +1012,10 @@ async function main() {
|
|
|
895
1012
|
process.exit(0);
|
|
896
1013
|
}
|
|
897
1014
|
|
|
1015
|
+
// Reject unknown / wrong-command flags AFTER the help+version early-returns
|
|
1016
|
+
// (so `trackly --help --weird` still shows help instead of erroring).
|
|
1017
|
+
validateFlags(args);
|
|
1018
|
+
|
|
898
1019
|
switch (args.command) {
|
|
899
1020
|
case 'login':
|
|
900
1021
|
await cmdLogin();
|
|
@@ -993,9 +1114,13 @@ if (require.main === module) {
|
|
|
993
1114
|
}
|
|
994
1115
|
|
|
995
1116
|
module.exports = {
|
|
1117
|
+
COMMAND_FLAGS,
|
|
1118
|
+
UNIVERSAL_FLAGS,
|
|
996
1119
|
getPublicConfig,
|
|
997
1120
|
hasJobListFilters,
|
|
1121
|
+
levenshtein,
|
|
998
1122
|
main,
|
|
1123
|
+
nearestFlag,
|
|
999
1124
|
normalizeBaseUrlValue,
|
|
1000
1125
|
openExternalUrl,
|
|
1001
1126
|
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/lib/formatters.js
CHANGED
|
@@ -6,7 +6,19 @@ const c = {
|
|
|
6
6
|
magenta: '\x1b[35m', cyan: '\x1b[36m', red: '\x1b[31m',
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// Decide whether to emit ANSI color for a given output stream.
|
|
10
|
+
// Precedence (no-color.org + the common FORCE_COLOR convention):
|
|
11
|
+
// NO_COLOR present and non-empty → never color
|
|
12
|
+
// FORCE_COLOR set and not "0"/"false" → always color
|
|
13
|
+
// otherwise → only when the stream is a TTY
|
|
14
|
+
function shouldColor(stream = process.stdout) {
|
|
15
|
+
if ('NO_COLOR' in process.env && process.env.NO_COLOR !== '') return false;
|
|
16
|
+
if (process.env.FORCE_COLOR && !['0', 'false'].includes(process.env.FORCE_COLOR)) return true;
|
|
17
|
+
return Boolean(stream && stream.isTTY);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function color(name, text, stream = process.stdout) {
|
|
21
|
+
if (!shouldColor(stream)) return String(text);
|
|
10
22
|
return `${c[name] || ''}${text}${c.reset}`;
|
|
11
23
|
}
|
|
12
24
|
|
|
@@ -314,7 +326,8 @@ function padRight(str, len) {
|
|
|
314
326
|
}
|
|
315
327
|
|
|
316
328
|
function outputError(msg) {
|
|
317
|
-
|
|
329
|
+
// Errors go to stderr, so gate their color on stderr's TTY-ness, not stdout's.
|
|
330
|
+
console.error(color('red', `Error: ${msg}`, process.stderr));
|
|
318
331
|
}
|
|
319
332
|
|
|
320
|
-
module.exports = { color, isJSON, outputJobs, outputCompanies, outputStats, outputContacts, outputReferralCampaign, outputNetworkBrief, outputCompanyBrief, outputCompanyWorkspace, outputError };
|
|
333
|
+
module.exports = { color, shouldColor, isJSON, outputJobs, outputCompanies, outputStats, outputContacts, outputReferralCampaign, outputNetworkBrief, outputCompanyBrief, outputCompanyWorkspace, outputError };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackly-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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.1",
|
|
21
21
|
"packages": [
|
|
22
22
|
{
|
|
23
23
|
"registryType": "npm",
|
|
24
24
|
"identifier": "trackly-cli",
|
|
25
|
-
"version": "0.
|
|
25
|
+
"version": "0.4.1",
|
|
26
26
|
"runtimeHint": "npx",
|
|
27
27
|
"runtimeArguments": [
|
|
28
28
|
{
|