trackly-cli 0.1.3 → 0.1.5
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 +54 -7
- package/bin/trackly +446 -122
- package/docs/trackly-tools.md +38 -1
- package/lib/client.js +174 -30
- package/mcp/server.js +175 -148
- package/package.json +6 -2
- package/server.json +42 -0
package/bin/trackly
CHANGED
|
@@ -1,44 +1,199 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const {
|
|
4
|
+
const {
|
|
5
|
+
DEFAULT_BASE_URL,
|
|
6
|
+
apiRequest,
|
|
7
|
+
clearConfig,
|
|
8
|
+
getApiKey,
|
|
9
|
+
getAuthMethod,
|
|
10
|
+
getBaseUrl,
|
|
11
|
+
getConfigPaths,
|
|
12
|
+
hasAuth,
|
|
13
|
+
loadConfig,
|
|
14
|
+
saveConfig,
|
|
15
|
+
} = require('../lib/client');
|
|
5
16
|
const { color, isJSON, outputJobs, outputCompanies, outputStats, outputError } = require('../lib/formatters');
|
|
6
17
|
const http = require('http');
|
|
7
18
|
const crypto = require('crypto');
|
|
8
|
-
const {
|
|
9
|
-
const
|
|
19
|
+
const { spawnSync } = require('child_process');
|
|
20
|
+
const { version: VERSION } = require('../package.json');
|
|
21
|
+
|
|
22
|
+
const BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'version', 'v', 'clear-api-key', 'clear-base-url']);
|
|
23
|
+
const VALUE_FLAGS = new Set([
|
|
24
|
+
'api-key',
|
|
25
|
+
'base-url',
|
|
26
|
+
'function',
|
|
27
|
+
'keywords',
|
|
28
|
+
'limit',
|
|
29
|
+
'location',
|
|
30
|
+
'modality',
|
|
31
|
+
'name',
|
|
32
|
+
'offset',
|
|
33
|
+
'sort',
|
|
34
|
+
'status',
|
|
35
|
+
]);
|
|
36
|
+
const AUTH_HELP_TEXT =
|
|
37
|
+
'Not authenticated. Run: trackly login or configure an API key with `trackly config --api-key <key>`';
|
|
38
|
+
const STATUS_LABELS = {
|
|
39
|
+
applied: 'applied',
|
|
40
|
+
saved: 'saved',
|
|
41
|
+
dismissed: 'dismissed',
|
|
42
|
+
};
|
|
10
43
|
|
|
11
44
|
function parseArgs(args) {
|
|
12
45
|
const result = { _: [], command: null };
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (!result.command) result.command = args[i];
|
|
16
|
-
else result._.push(args[i]);
|
|
17
|
-
i++;
|
|
18
|
-
}
|
|
19
|
-
while (i < args.length) {
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < args.length; i++) {
|
|
20
48
|
const arg = args[i];
|
|
49
|
+
|
|
50
|
+
if (arg === '--') {
|
|
51
|
+
result._.push(...args.slice(i + 1));
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (arg === '-h') {
|
|
56
|
+
result.help = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (arg === '-v') {
|
|
61
|
+
result.version = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
21
65
|
if (arg.startsWith('--')) {
|
|
66
|
+
const equalIndex = arg.indexOf('=');
|
|
67
|
+
if (equalIndex !== -1) {
|
|
68
|
+
const key = arg.slice(2, equalIndex);
|
|
69
|
+
const value = arg.slice(equalIndex + 1);
|
|
70
|
+
result[key] = value === '' ? true : value;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
22
74
|
const key = arg.slice(2);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
75
|
+
const nextArg = args[i + 1];
|
|
76
|
+
if (VALUE_FLAGS.has(key)) {
|
|
77
|
+
if (nextArg && !nextArg.startsWith('-')) {
|
|
78
|
+
result[key] = nextArg;
|
|
79
|
+
i++;
|
|
80
|
+
} else {
|
|
81
|
+
result[key] = null;
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const expectsValue = !BOOLEAN_FLAGS.has(key) && nextArg && !nextArg.startsWith('-');
|
|
87
|
+
|
|
88
|
+
if (expectsValue) {
|
|
89
|
+
result[key] = nextArg;
|
|
90
|
+
i++;
|
|
91
|
+
} else {
|
|
92
|
+
result[key] = true;
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!result.command) result.command = arg;
|
|
98
|
+
else result._.push(arg);
|
|
27
99
|
}
|
|
100
|
+
|
|
28
101
|
return result;
|
|
29
102
|
}
|
|
30
103
|
|
|
104
|
+
function failNow(message, exitCode = 1) {
|
|
105
|
+
outputError(message);
|
|
106
|
+
process.exit(exitCode);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function setErrorExit(message, exitCode = 1) {
|
|
110
|
+
outputError(message);
|
|
111
|
+
process.exitCode = exitCode;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function printJson(data) {
|
|
115
|
+
console.log(JSON.stringify(data, null, 2));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseJobId(value) {
|
|
119
|
+
const normalized = String(value || '').trim();
|
|
120
|
+
if (!/^\d+$/.test(normalized)) return null;
|
|
121
|
+
|
|
122
|
+
const id = Number(normalized);
|
|
123
|
+
if (!Number.isSafeInteger(id) || id <= 0) return null;
|
|
124
|
+
|
|
125
|
+
return id;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function hasJobListFilters(args) {
|
|
129
|
+
return ['function', 'keywords', 'limit', 'location', 'modality', 'offset', 'sort', 'status']
|
|
130
|
+
.some((key) => args[key] !== undefined);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function openExternalUrl(url) {
|
|
134
|
+
let command = null;
|
|
135
|
+
let args = [];
|
|
136
|
+
|
|
137
|
+
if (process.platform === 'darwin') {
|
|
138
|
+
command = 'open';
|
|
139
|
+
args = [url];
|
|
140
|
+
} else if (process.platform === 'linux') {
|
|
141
|
+
command = 'xdg-open';
|
|
142
|
+
args = [url];
|
|
143
|
+
} else if (process.platform === 'win32') {
|
|
144
|
+
command = 'cmd';
|
|
145
|
+
args = ['/c', 'start', '', url];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!command) return false;
|
|
149
|
+
|
|
150
|
+
const result = spawnSync(command, args, { stdio: 'ignore' });
|
|
151
|
+
return !result.error && result.status === 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeBaseUrlValue(rawValue) {
|
|
155
|
+
const value = String(rawValue || '').trim();
|
|
156
|
+
if (!value) {
|
|
157
|
+
throw new Error('Base URL cannot be empty');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const url = new URL(value);
|
|
161
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
162
|
+
throw new Error('Base URL must start with http:// or https://');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return value.replace(/\/+$/, '');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getPublicConfig() {
|
|
169
|
+
const config = loadConfig();
|
|
170
|
+
const { file } = getConfigPaths();
|
|
171
|
+
const authMethod = getAuthMethod();
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
configFile: file,
|
|
175
|
+
baseUrl: getBaseUrl(),
|
|
176
|
+
authMethod: authMethod === 'apiKey' ? 'api_key' : authMethod || 'none',
|
|
177
|
+
hasApiKey: Boolean(getApiKey()),
|
|
178
|
+
hasToken: Boolean(config.token),
|
|
179
|
+
hasRefreshToken: Boolean(config.refreshToken),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
31
183
|
// -- Commands --
|
|
32
184
|
|
|
33
185
|
async function cmdLogin() {
|
|
34
186
|
const port = 19847 + Math.floor(Math.random() * 1000);
|
|
35
|
-
const baseUrl =
|
|
187
|
+
const baseUrl = getBaseUrl();
|
|
36
188
|
const state = crypto.randomBytes(16).toString('hex');
|
|
37
189
|
|
|
38
190
|
console.log(color('cyan', 'Opening browser for Google login...'));
|
|
39
191
|
console.log(color('dim', `Callback server on port ${port}`));
|
|
40
192
|
|
|
41
193
|
return new Promise((resolve, reject) => {
|
|
194
|
+
let settled = false;
|
|
195
|
+
let timeoutId = null;
|
|
196
|
+
|
|
42
197
|
const server = http.createServer((req, res) => {
|
|
43
198
|
const url = new URL(req.url, `http://localhost:${port}`);
|
|
44
199
|
if (url.pathname === '/callback') {
|
|
@@ -46,10 +201,10 @@ async function cmdLogin() {
|
|
|
46
201
|
if (returnedState !== state) {
|
|
47
202
|
res.writeHead(403, { 'Content-Type': 'text/html' });
|
|
48
203
|
res.end('<html><body><h2>Login failed. Invalid state parameter (possible CSRF).</h2></body></html>');
|
|
49
|
-
|
|
50
|
-
reject(new Error('CSRF state mismatch'));
|
|
204
|
+
settle(new Error('CSRF state mismatch'));
|
|
51
205
|
return;
|
|
52
206
|
}
|
|
207
|
+
|
|
53
208
|
const token = url.searchParams.get('token');
|
|
54
209
|
const refreshToken = url.searchParams.get('refreshToken');
|
|
55
210
|
if (token) {
|
|
@@ -57,13 +212,11 @@ async function cmdLogin() {
|
|
|
57
212
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
58
213
|
res.end('<html><body><h2>Logged in! You can close this tab.</h2><script>window.close()</script></body></html>');
|
|
59
214
|
console.log(color('green', 'Login successful!'));
|
|
60
|
-
|
|
61
|
-
resolve();
|
|
215
|
+
settle();
|
|
62
216
|
} else {
|
|
63
217
|
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
64
218
|
res.end('<html><body><h2>Login failed. No token received.</h2></body></html>');
|
|
65
|
-
|
|
66
|
-
reject(new Error('No token received'));
|
|
219
|
+
settle(new Error('No token received'));
|
|
67
220
|
}
|
|
68
221
|
} else {
|
|
69
222
|
res.writeHead(404);
|
|
@@ -71,19 +224,34 @@ async function cmdLogin() {
|
|
|
71
224
|
}
|
|
72
225
|
});
|
|
73
226
|
|
|
227
|
+
const settle = (error = null) => {
|
|
228
|
+
if (settled) return;
|
|
229
|
+
settled = true;
|
|
230
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
231
|
+
|
|
232
|
+
const finish = () => {
|
|
233
|
+
if (error) reject(error);
|
|
234
|
+
else resolve();
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
if (server.listening) {
|
|
238
|
+
server.close(finish);
|
|
239
|
+
} else {
|
|
240
|
+
finish();
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
server.on('error', settle);
|
|
74
245
|
server.listen(port, '127.0.0.1', () => {
|
|
75
246
|
const loginUrl = `${baseUrl}/auth/google/cli?port=${port}&state=${state}`;
|
|
76
|
-
|
|
77
|
-
if (process.platform === 'darwin') execSync(`open "${loginUrl}"`);
|
|
78
|
-
else if (process.platform === 'linux') execSync(`xdg-open "${loginUrl}"`);
|
|
79
|
-
else console.log(`Open this URL in your browser:\n${loginUrl}`);
|
|
80
|
-
} catch {
|
|
247
|
+
if (!openExternalUrl(loginUrl)) {
|
|
81
248
|
console.log(`Open this URL in your browser:\n${loginUrl}`);
|
|
82
249
|
}
|
|
83
250
|
});
|
|
84
251
|
|
|
85
|
-
|
|
86
|
-
|
|
252
|
+
timeoutId = setTimeout(() => {
|
|
253
|
+
settle(new Error('Login timed out'));
|
|
254
|
+
}, 120000);
|
|
87
255
|
});
|
|
88
256
|
}
|
|
89
257
|
|
|
@@ -93,24 +261,30 @@ async function cmdLogout() {
|
|
|
93
261
|
}
|
|
94
262
|
|
|
95
263
|
async function cmdWhoami() {
|
|
96
|
-
if (!
|
|
264
|
+
if (!hasAuth()) failNow(AUTH_HELP_TEXT);
|
|
265
|
+
|
|
97
266
|
try {
|
|
98
267
|
const result = await apiRequest('GET', '/api/auth/user');
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
console.log(
|
|
268
|
+
const user = result.user || result;
|
|
269
|
+
|
|
270
|
+
if (result.success || result.user || result.email) {
|
|
271
|
+
if (isJSON()) return printJson(user);
|
|
272
|
+
console.log(`${color('bold', user.name || 'Unknown')}`);
|
|
273
|
+
console.log(` Email: ${color('cyan', user.email)}`);
|
|
274
|
+
if (user.id !== undefined) {
|
|
275
|
+
console.log(` ID: ${color('dim', user.id)}`);
|
|
276
|
+
}
|
|
104
277
|
} else {
|
|
105
|
-
|
|
278
|
+
setErrorExit(AUTH_HELP_TEXT);
|
|
106
279
|
}
|
|
107
280
|
} catch (e) {
|
|
108
|
-
|
|
281
|
+
setErrorExit(e.error || e.message || 'Failed to get user info');
|
|
109
282
|
}
|
|
110
283
|
}
|
|
111
284
|
|
|
112
285
|
async function cmdJobs(args) {
|
|
113
|
-
if (!
|
|
286
|
+
if (!hasAuth()) failNow(AUTH_HELP_TEXT);
|
|
287
|
+
|
|
114
288
|
const params = new URLSearchParams();
|
|
115
289
|
if (args.function) params.set('function', args.function);
|
|
116
290
|
if (args.location) params.set('location', args.location);
|
|
@@ -125,19 +299,23 @@ async function cmdJobs(args) {
|
|
|
125
299
|
try {
|
|
126
300
|
const result = await apiRequest('GET', `/api/jobscout/jobs${qs ? '?' + qs : ''}`);
|
|
127
301
|
outputJobs(result.jobs || result.data || []);
|
|
128
|
-
if (!isJSON() && result.total)
|
|
302
|
+
if (!isJSON() && result.total) {
|
|
303
|
+
console.log(color('dim', `Showing ${(result.jobs || result.data || []).length} of ${result.total} jobs`));
|
|
304
|
+
}
|
|
129
305
|
} catch (e) {
|
|
130
|
-
|
|
306
|
+
setErrorExit(e.error || e.message || 'Failed to fetch jobs');
|
|
131
307
|
}
|
|
132
308
|
}
|
|
133
309
|
|
|
134
310
|
async function cmdJob(args) {
|
|
135
|
-
const id = args._[0];
|
|
136
|
-
if (
|
|
137
|
-
if (!
|
|
311
|
+
const id = parseJobId(args._[0]);
|
|
312
|
+
if (id === null) failNow('Job ID must be a positive integer. Usage: trackly job <id>');
|
|
313
|
+
if (!hasAuth()) failNow(AUTH_HELP_TEXT);
|
|
314
|
+
|
|
138
315
|
try {
|
|
139
316
|
const result = await apiRequest('GET', `/api/jobscout/jobs/${id}`);
|
|
140
|
-
if (isJSON()) return
|
|
317
|
+
if (isJSON()) return printJson(result);
|
|
318
|
+
|
|
141
319
|
const job = result.job || result;
|
|
142
320
|
console.log(color('bold', job.title));
|
|
143
321
|
console.log(`Company: ${color('blue', job.company_name)}`);
|
|
@@ -152,150 +330,220 @@ async function cmdJob(args) {
|
|
|
152
330
|
if (job.description_text.length > 1000) console.log(color('dim', '... (truncated)'));
|
|
153
331
|
}
|
|
154
332
|
} catch (e) {
|
|
155
|
-
|
|
333
|
+
setErrorExit(e.error || e.message || 'Failed to fetch job');
|
|
156
334
|
}
|
|
157
335
|
}
|
|
158
336
|
|
|
159
337
|
async function cmdCompanies(args) {
|
|
160
|
-
if (!
|
|
338
|
+
if (!hasAuth()) failNow(AUTH_HELP_TEXT);
|
|
339
|
+
|
|
340
|
+
const params = new URLSearchParams();
|
|
341
|
+
if (args.limit) params.set('limit', args.limit);
|
|
342
|
+
if (args.offset) params.set('offset', args.offset);
|
|
343
|
+
|
|
161
344
|
try {
|
|
162
|
-
const
|
|
345
|
+
const qs = params.toString();
|
|
346
|
+
const result = await apiRequest('GET', `/api/jobscout/companies${qs ? '?' + qs : ''}`);
|
|
163
347
|
outputCompanies(result.companies || result.data || []);
|
|
164
348
|
} catch (e) {
|
|
165
|
-
|
|
349
|
+
setErrorExit(e.error || e.message || 'Failed to fetch companies');
|
|
166
350
|
}
|
|
167
351
|
}
|
|
168
352
|
|
|
169
353
|
async function cmdSearch(args) {
|
|
170
|
-
const query = args._
|
|
171
|
-
if (!query)
|
|
172
|
-
if (!
|
|
354
|
+
const query = args._.join(' ').trim();
|
|
355
|
+
if (!query) failNow('Search query required. Usage: trackly search "fintech"');
|
|
356
|
+
if (!hasAuth()) failNow(AUTH_HELP_TEXT);
|
|
357
|
+
|
|
173
358
|
try {
|
|
174
359
|
const result = await apiRequest('GET', `/api/jobscout/companies/search?q=${encodeURIComponent(query)}`);
|
|
175
360
|
outputCompanies(result.companies || result.results || []);
|
|
176
361
|
} catch (e) {
|
|
177
|
-
|
|
362
|
+
setErrorExit(e.error || e.message || 'Failed to search');
|
|
178
363
|
}
|
|
179
364
|
}
|
|
180
365
|
|
|
181
366
|
async function cmdStats() {
|
|
182
|
-
if (!
|
|
367
|
+
if (!hasAuth()) failNow(AUTH_HELP_TEXT);
|
|
368
|
+
|
|
183
369
|
try {
|
|
184
370
|
const result = await apiRequest('GET', '/api/jobscout/me');
|
|
185
371
|
outputStats(result);
|
|
186
372
|
} catch (e) {
|
|
187
|
-
|
|
373
|
+
setErrorExit(e.error || e.message || 'Failed to fetch stats');
|
|
188
374
|
}
|
|
189
375
|
}
|
|
190
376
|
|
|
191
377
|
async function cmdUpdateStatus(id, action) {
|
|
192
|
-
|
|
193
|
-
if (
|
|
378
|
+
const jobId = parseJobId(id);
|
|
379
|
+
if (jobId === null) failNow(`Job ID must be a positive integer. Usage: trackly ${action} <id>`);
|
|
380
|
+
if (!hasAuth()) failNow(AUTH_HELP_TEXT);
|
|
381
|
+
|
|
194
382
|
try {
|
|
195
|
-
const result = await apiRequest('POST', '/api/jobscout-tracker/status', { jobId
|
|
196
|
-
if (isJSON()) return
|
|
197
|
-
console.log(color('green', `Job ${
|
|
383
|
+
const result = await apiRequest('POST', '/api/jobscout-tracker/status', { jobId, action });
|
|
384
|
+
if (isJSON()) return printJson(result);
|
|
385
|
+
console.log(color('green', `Job ${jobId} marked as ${STATUS_LABELS[action]}.`));
|
|
198
386
|
} catch (e) {
|
|
199
|
-
|
|
387
|
+
setErrorExit(e.error || e.message || `Failed to ${action} job`);
|
|
200
388
|
}
|
|
201
389
|
}
|
|
202
390
|
|
|
203
391
|
async function cmdAsk(args) {
|
|
204
392
|
const query = args._.join(' ');
|
|
205
|
-
if (!query)
|
|
206
|
-
if (!
|
|
393
|
+
if (!query) failNow('Query required. Usage: trackly ask "PM jobs at fintech"');
|
|
394
|
+
if (!hasAuth()) failNow(AUTH_HELP_TEXT);
|
|
395
|
+
|
|
207
396
|
try {
|
|
208
397
|
const result = await apiRequest('GET', `/api/jobscout/ask?q=${encodeURIComponent(query)}`);
|
|
209
|
-
if (isJSON()) return
|
|
398
|
+
if (isJSON()) return printJson(result);
|
|
399
|
+
|
|
210
400
|
console.log(color('bold', 'Parsed filters:'));
|
|
211
401
|
for (const [k, v] of Object.entries(result.parsedFilters || {})) {
|
|
212
402
|
console.log(` ${k}: ${color('cyan', String(v))}`);
|
|
213
403
|
}
|
|
214
|
-
|
|
404
|
+
|
|
405
|
+
if (result.jobsUrl) {
|
|
215
406
|
console.log();
|
|
216
407
|
console.log(color('dim', `Fetching: ${result.jobsUrl}`));
|
|
217
|
-
// Auto-fetch jobs with parsed filters
|
|
218
408
|
const jobsResult = await apiRequest('GET', result.jobsUrl);
|
|
219
409
|
console.log();
|
|
220
410
|
outputJobs(jobsResult.jobs || jobsResult.data || []);
|
|
221
411
|
}
|
|
222
412
|
} catch (e) {
|
|
223
|
-
if (e.status === 429)
|
|
224
|
-
else
|
|
413
|
+
if (e.status === 429) setErrorExit('Daily limit reached (20 natural language queries per day)');
|
|
414
|
+
else setErrorExit(e.error || e.message || 'Failed to process query');
|
|
225
415
|
}
|
|
226
416
|
}
|
|
227
417
|
|
|
228
418
|
async function cmdApiKey(args) {
|
|
229
419
|
const subcommand = args._[0] || 'create';
|
|
230
|
-
if (!
|
|
420
|
+
if (!hasAuth()) failNow(AUTH_HELP_TEXT);
|
|
421
|
+
|
|
231
422
|
if (subcommand === 'create') {
|
|
232
423
|
const name = args.name || 'default';
|
|
233
424
|
try {
|
|
234
425
|
const result = await apiRequest('POST', '/api/auth/api-key', { name });
|
|
235
|
-
if (isJSON()) return
|
|
426
|
+
if (isJSON()) return printJson(result);
|
|
236
427
|
console.log(color('green', 'API key created!'));
|
|
237
428
|
console.log();
|
|
238
429
|
console.log(`Key: ${color('bold', result.key)}`);
|
|
239
430
|
console.log(color('yellow', 'Save this key — it cannot be shown again.'));
|
|
431
|
+
console.log(color('dim', 'Tip: run `trackly config --api-key <key>` or set TRACKLY_API_KEY to use it.'));
|
|
240
432
|
} catch (e) {
|
|
241
|
-
|
|
433
|
+
setErrorExit(e.error || e.message || 'Failed to create API key');
|
|
242
434
|
}
|
|
243
435
|
} else if (subcommand === 'list') {
|
|
244
436
|
try {
|
|
245
437
|
const result = await apiRequest('GET', '/api/auth/api-keys');
|
|
246
|
-
if (isJSON()) return
|
|
438
|
+
if (isJSON()) return printJson(result.keys || []);
|
|
247
439
|
for (const key of (result.keys || [])) {
|
|
248
440
|
console.log(`${color('bold', key.key_prefix + '...')} — ${key.name} (created ${key.created_at})`);
|
|
249
441
|
}
|
|
250
442
|
} catch (e) {
|
|
251
|
-
|
|
443
|
+
setErrorExit(e.error || e.message || 'Failed to list keys');
|
|
252
444
|
}
|
|
445
|
+
} else {
|
|
446
|
+
failNow('Unknown api-key subcommand. Use: trackly api-key create | list');
|
|
253
447
|
}
|
|
254
448
|
}
|
|
255
449
|
|
|
450
|
+
async function cmdConfig(args) {
|
|
451
|
+
if (args['api-key'] === null) failNow('API key value required. Usage: trackly config --api-key <key>');
|
|
452
|
+
if (args['base-url'] === null) failNow('Base URL value required. Usage: trackly config --base-url <url>');
|
|
453
|
+
|
|
454
|
+
const config = loadConfig();
|
|
455
|
+
let updated = false;
|
|
456
|
+
|
|
457
|
+
if (args['clear-api-key']) {
|
|
458
|
+
delete config.apiKey;
|
|
459
|
+
updated = true;
|
|
460
|
+
console.log(color('green', 'Stored API key cleared.'));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (args['clear-base-url']) {
|
|
464
|
+
delete config.baseUrl;
|
|
465
|
+
updated = true;
|
|
466
|
+
console.log(color('green', `Base URL reset to default (${DEFAULT_BASE_URL}).`));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (args['api-key'] !== undefined) {
|
|
470
|
+
config.apiKey = String(args['api-key']).trim();
|
|
471
|
+
updated = true;
|
|
472
|
+
console.log(color('green', 'API key saved. It will take precedence over OAuth tokens.'));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (args['base-url'] !== undefined) {
|
|
476
|
+
config.baseUrl = normalizeBaseUrlValue(args['base-url']);
|
|
477
|
+
updated = true;
|
|
478
|
+
console.log(color('green', `Base URL set to ${config.baseUrl}.`));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (updated) {
|
|
482
|
+
saveConfig(config);
|
|
483
|
+
console.log();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const publicConfig = getPublicConfig();
|
|
487
|
+
if (isJSON()) return printJson(publicConfig);
|
|
488
|
+
|
|
489
|
+
console.log(color('bold', 'Trackly Config'));
|
|
490
|
+
console.log(`Config file: ${color('dim', publicConfig.configFile)}`);
|
|
491
|
+
console.log(`Base URL: ${color('cyan', publicConfig.baseUrl)}`);
|
|
492
|
+
console.log(`Auth method: ${color('cyan', publicConfig.authMethod)}`);
|
|
493
|
+
console.log(`Stored API key: ${color(publicConfig.hasApiKey ? 'green' : 'dim', publicConfig.hasApiKey ? 'yes' : 'no')}`);
|
|
494
|
+
console.log(`Stored token: ${color(publicConfig.hasToken ? 'green' : 'dim', publicConfig.hasToken ? 'yes' : 'no')}`);
|
|
495
|
+
console.log(
|
|
496
|
+
`Refresh token: ${color(publicConfig.hasRefreshToken ? 'green' : 'dim', publicConfig.hasRefreshToken ? 'yes' : 'no')}`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function cmdVersion() {
|
|
501
|
+
console.log(VERSION);
|
|
502
|
+
}
|
|
503
|
+
|
|
256
504
|
async function cmdMcp() {
|
|
257
|
-
|
|
258
|
-
require('../mcp/server');
|
|
505
|
+
await require('../mcp/server').startMcpServer();
|
|
259
506
|
}
|
|
260
507
|
|
|
261
508
|
function showHelp() {
|
|
262
509
|
console.log(`
|
|
263
|
-
${color('bold', 'trackly')} — Trackly job tracker CLI + MCP server
|
|
510
|
+
${color('bold', 'trackly')} v${VERSION} — Trackly job tracker CLI + MCP server
|
|
264
511
|
|
|
265
512
|
${color('bold', 'Usage:')}
|
|
266
513
|
trackly <command> [options]
|
|
267
514
|
|
|
268
515
|
${color('bold', 'Commands:')}
|
|
269
|
-
${color('cyan', 'login')}
|
|
270
|
-
${color('cyan', 'logout')}
|
|
271
|
-
${color('cyan', 'whoami')}
|
|
272
|
-
${color('cyan', 'jobs')}
|
|
273
|
-
${color('cyan', 'job <id>')}
|
|
274
|
-
${color('cyan', '
|
|
275
|
-
${color('cyan', '
|
|
276
|
-
${color('cyan', '
|
|
277
|
-
${color('cyan', '
|
|
278
|
-
${color('cyan', '
|
|
279
|
-
${color('cyan', '
|
|
280
|
-
${color('cyan', '
|
|
281
|
-
${color('cyan', '
|
|
282
|
-
${color('cyan', '
|
|
283
|
-
${color('cyan', '
|
|
516
|
+
${color('cyan', 'login')} Authenticate via Google OAuth
|
|
517
|
+
${color('cyan', 'logout')} Clear credentials
|
|
518
|
+
${color('cyan', 'whoami')} Show current user
|
|
519
|
+
${color('cyan', 'jobs')} List jobs (--function, --location, --modality, --status, --sort, --limit, --offset)
|
|
520
|
+
${color('cyan', 'job <id>')} Get full job detail
|
|
521
|
+
${color('cyan', 'jobs <id>')} Alias for job <id>
|
|
522
|
+
${color('cyan', 'companies')} List companies
|
|
523
|
+
${color('cyan', 'companies search <q>')} Alias for semantic company search
|
|
524
|
+
${color('cyan', 'search <query>')} Semantic company search
|
|
525
|
+
${color('cyan', 'stats')} Show metrics dashboard
|
|
526
|
+
${color('cyan', 'status')} Alias for stats
|
|
527
|
+
${color('cyan', 'apply <id>')} Mark job as applied
|
|
528
|
+
${color('cyan', 'save <id>')} Save a job
|
|
529
|
+
${color('cyan', 'dismiss <id>')} Dismiss a job
|
|
530
|
+
${color('cyan', 'ask "query"')} Natural language job search (20/day)
|
|
531
|
+
${color('cyan', 'api-key create')} Generate an API key
|
|
532
|
+
${color('cyan', 'api-key list')} List your API keys
|
|
533
|
+
${color('cyan', 'config')} Show or update CLI config (--api-key, --base-url)
|
|
534
|
+
${color('cyan', 'version')} Show CLI version
|
|
535
|
+
${color('cyan', 'mcp')} Start MCP server
|
|
284
536
|
|
|
285
537
|
${color('bold', 'Flags:')}
|
|
286
|
-
--
|
|
287
|
-
--
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
"args": ["mcp"]
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
538
|
+
--api-key <key> Use an API key for this command
|
|
539
|
+
--base-url <url> Override the Trackly API base URL for this command
|
|
540
|
+
--json Force JSON output
|
|
541
|
+
--version Show version
|
|
542
|
+
--help Show this help
|
|
543
|
+
|
|
544
|
+
${color('bold', 'MCP Setup:')}
|
|
545
|
+
Claude Code: claude mcp add-json trackly '{"command":"trackly","args":["mcp"]}'
|
|
546
|
+
Cursor: add {"command":"trackly","args":["mcp"]} to .cursor/mcp.json
|
|
299
547
|
`);
|
|
300
548
|
}
|
|
301
549
|
|
|
@@ -304,31 +552,107 @@ ${color('bold', 'MCP Setup (Claude Code):')}
|
|
|
304
552
|
async function main() {
|
|
305
553
|
const args = parseArgs(process.argv.slice(2));
|
|
306
554
|
|
|
307
|
-
if (
|
|
555
|
+
if (args.command !== 'config' && args['api-key'] === null) {
|
|
556
|
+
failNow('API key value required. Usage: trackly --api-key <key> <command>');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (args.command !== 'config' && args['base-url'] === null) {
|
|
560
|
+
failNow('Base URL value required. Usage: trackly --base-url <url> <command>');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (args.command !== 'config' && args['api-key']) {
|
|
564
|
+
process.env.TRACKLY_API_KEY = String(args['api-key']);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (args.command !== 'config' && args['base-url']) {
|
|
568
|
+
process.env.TRACKLY_BASE_URL = normalizeBaseUrlValue(args['base-url']);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (args.version || args.v || args.command === 'version') {
|
|
572
|
+
await cmdVersion();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (!args.command || args.help || args.h) {
|
|
577
|
+
showHelp();
|
|
578
|
+
process.exit(0);
|
|
579
|
+
}
|
|
308
580
|
|
|
309
581
|
switch (args.command) {
|
|
310
|
-
case 'login':
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
case '
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
case '
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
case '
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
582
|
+
case 'login':
|
|
583
|
+
await cmdLogin();
|
|
584
|
+
break;
|
|
585
|
+
case 'logout':
|
|
586
|
+
await cmdLogout();
|
|
587
|
+
break;
|
|
588
|
+
case 'whoami':
|
|
589
|
+
await cmdWhoami();
|
|
590
|
+
break;
|
|
591
|
+
case 'jobs':
|
|
592
|
+
if (parseJobId(args._[0]) !== null && !hasJobListFilters(args)) {
|
|
593
|
+
await cmdJob({ ...args, _: [args._[0]] });
|
|
594
|
+
} else {
|
|
595
|
+
await cmdJobs(args);
|
|
596
|
+
}
|
|
597
|
+
break;
|
|
598
|
+
case 'job':
|
|
599
|
+
await cmdJob(args);
|
|
600
|
+
break;
|
|
601
|
+
case 'companies':
|
|
602
|
+
if (args._[0] === 'search') {
|
|
603
|
+
await cmdSearch({ ...args, _: args._.slice(1) });
|
|
604
|
+
} else {
|
|
605
|
+
await cmdCompanies(args);
|
|
606
|
+
}
|
|
607
|
+
break;
|
|
608
|
+
case 'search':
|
|
609
|
+
await cmdSearch(args);
|
|
610
|
+
break;
|
|
611
|
+
case 'stats':
|
|
612
|
+
case 'status':
|
|
613
|
+
await cmdStats();
|
|
614
|
+
break;
|
|
615
|
+
case 'apply':
|
|
616
|
+
await cmdUpdateStatus(args._[0], 'applied');
|
|
617
|
+
break;
|
|
618
|
+
case 'save':
|
|
619
|
+
await cmdUpdateStatus(args._[0], 'saved');
|
|
620
|
+
break;
|
|
621
|
+
case 'dismiss':
|
|
622
|
+
await cmdUpdateStatus(args._[0], 'dismissed');
|
|
623
|
+
break;
|
|
624
|
+
case 'ask':
|
|
625
|
+
await cmdAsk(args);
|
|
626
|
+
break;
|
|
627
|
+
case 'api-key':
|
|
628
|
+
await cmdApiKey(args);
|
|
629
|
+
break;
|
|
630
|
+
case 'config':
|
|
631
|
+
await cmdConfig(args);
|
|
632
|
+
break;
|
|
633
|
+
case 'mcp':
|
|
634
|
+
await cmdMcp();
|
|
635
|
+
break;
|
|
324
636
|
default:
|
|
325
|
-
|
|
637
|
+
setErrorExit(`Unknown command: ${args.command}`);
|
|
326
638
|
showHelp();
|
|
327
639
|
process.exit(1);
|
|
328
640
|
}
|
|
329
641
|
}
|
|
330
642
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
643
|
+
if (require.main === module) {
|
|
644
|
+
main().catch((e) => {
|
|
645
|
+
outputError(e.message || String(e));
|
|
646
|
+
process.exit(1);
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
module.exports = {
|
|
651
|
+
getPublicConfig,
|
|
652
|
+
hasJobListFilters,
|
|
653
|
+
main,
|
|
654
|
+
normalizeBaseUrlValue,
|
|
655
|
+
openExternalUrl,
|
|
656
|
+
parseArgs,
|
|
657
|
+
parseJobId,
|
|
658
|
+
};
|