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/bin/trackly CHANGED
@@ -1,44 +1,199 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const { loadConfig, saveConfig, clearConfig, apiRequest, getToken, CONFIG_DIR } = require('../lib/client');
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 { execSync } = require('child_process');
9
- const path = require('path');
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
- let i = 0;
14
- while (i < args.length && !args[i].startsWith('-')) {
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
- if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
24
- result[key] = args[i + 1]; i += 2;
25
- } else { result[key] = true; i++; }
26
- } else { result._.push(arg); i++; }
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 = loadConfig().baseUrl || 'https://closeai.mba';
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
- server.close();
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
- server.close();
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
- server.close();
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
- try {
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
- // Timeout after 2 minutes
86
- setTimeout(() => { server.close(); reject(new Error('Login timed out')); }, 120000);
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 (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
264
+ if (!hasAuth()) failNow(AUTH_HELP_TEXT);
265
+
97
266
  try {
98
267
  const result = await apiRequest('GET', '/api/auth/user');
99
- if (result.success && result.user) {
100
- if (isJSON()) return console.log(JSON.stringify(result.user, null, 2));
101
- console.log(`${color('bold', result.user.name || 'Unknown')}`);
102
- console.log(` Email: ${color('cyan', result.user.email)}`);
103
- console.log(` ID: ${color('dim', result.user.id)}`);
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
- outputError('Not authenticated. Run: trackly login');
278
+ setErrorExit(AUTH_HELP_TEXT);
106
279
  }
107
280
  } catch (e) {
108
- outputError(e.error || e.message || 'Failed to get user info');
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 (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
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) console.log(color('dim', `Showing ${(result.jobs || result.data || []).length} of ${result.total} jobs`));
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
- outputError(e.error || e.message || 'Failed to fetch jobs');
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 (!id) { outputError('Job ID required. Usage: trackly job <id>'); process.exit(1); }
137
- if (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
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 console.log(JSON.stringify(result, null, 2));
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
- outputError(e.error || e.message || 'Failed to fetch job');
333
+ setErrorExit(e.error || e.message || 'Failed to fetch job');
156
334
  }
157
335
  }
158
336
 
159
337
  async function cmdCompanies(args) {
160
- if (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
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 result = await apiRequest('GET', '/api/jobscout/companies');
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
- outputError(e.error || e.message || 'Failed to fetch companies');
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._[0];
171
- if (!query) { outputError('Search query required. Usage: trackly search "fintech"'); process.exit(1); }
172
- if (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
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
- outputError(e.error || e.message || 'Failed to search');
362
+ setErrorExit(e.error || e.message || 'Failed to search');
178
363
  }
179
364
  }
180
365
 
181
366
  async function cmdStats() {
182
- if (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
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
- outputError(e.error || e.message || 'Failed to fetch stats');
373
+ setErrorExit(e.error || e.message || 'Failed to fetch stats');
188
374
  }
189
375
  }
190
376
 
191
377
  async function cmdUpdateStatus(id, action) {
192
- if (!id) { outputError(`Job ID required. Usage: trackly ${action} <id>`); process.exit(1); }
193
- if (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
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: parseInt(id), action });
196
- if (isJSON()) return console.log(JSON.stringify(result, null, 2));
197
- console.log(color('green', `Job ${id} marked as ${action}d`));
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
- outputError(e.error || e.message || `Failed to ${action} job`);
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) { outputError('Query required. Usage: trackly ask "PM jobs at fintech"'); process.exit(1); }
206
- if (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
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 console.log(JSON.stringify(result, null, 2));
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
- if (result.jobsUrl && result.jobsUrl.startsWith('/api/')) {
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) outputError('Daily limit reached (20 natural language queries per day)');
224
- else outputError(e.error || e.message || 'Failed to process query');
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 (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
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 console.log(JSON.stringify(result, null, 2));
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
- outputError(e.error || e.message || 'Failed to create API key');
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 console.log(JSON.stringify(result.keys, null, 2));
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
- outputError(e.error || e.message || 'Failed to list keys');
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
- // Delegate to MCP server
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')} Authenticate via Google OAuth
270
- ${color('cyan', 'logout')} Clear credentials
271
- ${color('cyan', 'whoami')} Show current user
272
- ${color('cyan', 'jobs')} List jobs (--function, --location, --modality, --status, --sort, --limit, --offset)
273
- ${color('cyan', 'job <id>')} Get full job detail
274
- ${color('cyan', 'companies')} List companies
275
- ${color('cyan', 'search <query>')} Semantic company search
276
- ${color('cyan', 'stats')} Show metrics dashboard
277
- ${color('cyan', 'apply <id>')} Mark job as applied
278
- ${color('cyan', 'save <id>')} Save a job
279
- ${color('cyan', 'dismiss <id>')} Dismiss a job
280
- ${color('cyan', 'ask "query"')} Natural language job search (20/day)
281
- ${color('cyan', 'api-key create')} Generate an API key
282
- ${color('cyan', 'api-key list')} List your API keys
283
- ${color('cyan', 'mcp')} Start MCP server (for Claude Code)
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
- --json Force JSON output
287
- --help Show this help
288
-
289
- ${color('bold', 'MCP Setup (Claude Code):')}
290
- Add to ~/.claude/settings.json:
291
- {
292
- "mcpServers": {
293
- "trackly": {
294
- "command": "trackly",
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 (!args.command || args.help || args.h) { showHelp(); process.exit(0); }
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': await cmdLogin(); break;
311
- case 'logout': await cmdLogout(); break;
312
- case 'whoami': await cmdWhoami(); break;
313
- case 'jobs': await cmdJobs(args); break;
314
- case 'job': await cmdJob(args); break;
315
- case 'companies': await cmdCompanies(args); break;
316
- case 'search': await cmdSearch(args); break;
317
- case 'stats': await cmdStats(); break;
318
- case 'apply': await cmdUpdateStatus(args._[0], 'applied'); break;
319
- case 'save': await cmdUpdateStatus(args._[0], 'saved'); break;
320
- case 'dismiss': await cmdUpdateStatus(args._[0], 'dismissed'); break;
321
- case 'ask': await cmdAsk(args); break;
322
- case 'api-key': await cmdApiKey(args); break;
323
- case 'mcp': await cmdMcp(); break;
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
- outputError(`Unknown command: ${args.command}`);
637
+ setErrorExit(`Unknown command: ${args.command}`);
326
638
  showHelp();
327
639
  process.exit(1);
328
640
  }
329
641
  }
330
642
 
331
- main().catch(e => {
332
- outputError(e.message || String(e));
333
- process.exit(1);
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
+ };