trackly-cli 0.1.4 → 0.1.6

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
@@ -105,37 +105,55 @@ Then use natural language in Claude Code or Cursor:
105
105
  | trackly_update_status | Mark jobs as applied, saved, or dismissed |
106
106
  | trackly_ask | Natural language job search (20/day) |
107
107
 
108
- ## API Key
108
+ ## Authentication
109
109
 
110
- For programmatic access without OAuth:
110
+ ### Option 1: Google OAuth (recommended)
111
111
 
112
112
  ```bash
113
- trackly api-key create --name "my-script"
113
+ trackly login
114
114
  ```
115
115
 
116
- Use the generated key with any of these flows:
116
+ Opens your browser for Google sign-in. Tokens are stored locally at `~/.trackly/config.json`.
117
+
118
+ ### Option 2: API Key
119
+
120
+ If OAuth doesn't work (firewalls, headless servers, CI), use an API key instead:
121
+
122
+ 1. Sign in at [usetrackly.app](https://usetrackly.app)
123
+ 2. Go to **Settings → API Keys → Create**
124
+ 3. Save the key:
117
125
 
118
126
  ```bash
119
- # Save it locally for future commands
120
127
  trackly config --api-key trk_xxxxxxxxxxxxxxxxxxxx
128
+ ```
129
+
130
+ Or pass it per-command:
121
131
 
122
- # Use it for one command
132
+ ```bash
123
133
  trackly --api-key trk_xxxxxxxxxxxxxxxxxxxx jobs --json
134
+ ```
135
+
136
+ Or set it as an environment variable:
124
137
 
125
- # Or use an environment variable
126
- TRACKLY_API_KEY=trk_xxxxxxxxxxxxxxxxxxxx trackly jobs --json
138
+ ```bash
139
+ export TRACKLY_API_KEY=trk_xxxxxxxxxxxxxxxxxxxx
140
+ trackly jobs
127
141
  ```
128
142
 
129
- To clear a stored key:
143
+ ### Generate a key from the CLI
144
+
145
+ If you're already logged in via OAuth, you can create a key without visiting the web app:
130
146
 
131
147
  ```bash
132
- trackly config --clear-api-key
148
+ trackly api-key create --name "my-script"
149
+ trackly api-key list
133
150
  ```
134
151
 
135
- To point the CLI at a different backend:
152
+ ### Other config
136
153
 
137
154
  ```bash
138
- trackly config --base-url http://127.0.0.1:3000
155
+ trackly config --clear-api-key # Clear stored API key
156
+ trackly config --base-url http://127.0.0.1:3000 # Point at a different backend
139
157
  ```
140
158
 
141
159
  ## Comparison
package/bin/trackly CHANGED
@@ -13,13 +13,13 @@ const {
13
13
  loadConfig,
14
14
  saveConfig,
15
15
  } = require('../lib/client');
16
- const { color, isJSON, outputJobs, outputCompanies, outputStats, outputError } = require('../lib/formatters');
16
+ const { color, isJSON, outputJobs, outputCompanies, outputStats, outputContacts, outputReferralCampaign, outputNetworkBrief, outputCompanyBrief, outputCompanyWorkspace, outputError } = require('../lib/formatters');
17
17
  const http = require('http');
18
18
  const crypto = require('crypto');
19
19
  const { spawnSync } = require('child_process');
20
20
  const { version: VERSION } = require('../package.json');
21
21
 
22
- const BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'version', 'v', 'clear-api-key', 'clear-base-url']);
22
+ const BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'version', 'v', 'clear-api-key', 'clear-base-url', 'refresh']);
23
23
  const VALUE_FLAGS = new Set([
24
24
  'api-key',
25
25
  'base-url',
@@ -30,11 +30,14 @@ const VALUE_FLAGS = new Set([
30
30
  'modality',
31
31
  'name',
32
32
  'offset',
33
+ 'search',
33
34
  'sort',
34
35
  'status',
35
36
  ]);
36
- const AUTH_HELP_TEXT =
37
- 'Not authenticated. Run: trackly login or configure an API key with `trackly config --api-key <key>`';
37
+ const AUTH_HELP_TEXT = `Not authenticated.
38
+ Option 1: trackly login (Google OAuth)
39
+ Option 2: trackly config --api-key trk_xxx (get key at usetrackly.app)
40
+ Option 3: TRACKLY_API_KEY=trk_xxx trackly ... (environment variable)`;
38
41
  const STATUS_LABELS = {
39
42
  applied: 'applied',
40
43
  saved: 'saved',
@@ -183,76 +186,111 @@ function getPublicConfig() {
183
186
  // -- Commands --
184
187
 
185
188
  async function cmdLogin() {
186
- const port = 19847 + Math.floor(Math.random() * 1000);
187
189
  const baseUrl = getBaseUrl();
188
190
  const state = crypto.randomBytes(16).toString('hex');
189
191
 
190
192
  console.log(color('cyan', 'Opening browser for Google login...'));
191
- console.log(color('dim', `Callback server on port ${port}`));
192
-
193
- return new Promise((resolve, reject) => {
194
- let settled = false;
195
- let timeoutId = null;
196
-
197
- const server = http.createServer((req, res) => {
198
- const url = new URL(req.url, `http://localhost:${port}`);
199
- if (url.pathname === '/callback') {
200
- const returnedState = url.searchParams.get('state');
201
- if (returnedState !== state) {
202
- res.writeHead(403, { 'Content-Type': 'text/html' });
203
- res.end('<html><body><h2>Login failed. Invalid state parameter (possible CSRF).</h2></body></html>');
204
- settle(new Error('CSRF state mismatch'));
205
- return;
206
- }
207
193
 
208
- const token = url.searchParams.get('token');
209
- const refreshToken = url.searchParams.get('refreshToken');
210
- if (token) {
211
- saveConfig({ ...loadConfig(), token, refreshToken, baseUrl });
212
- res.writeHead(200, { 'Content-Type': 'text/html' });
213
- res.end('<html><body><h2>Logged in! You can close this tab.</h2><script>window.close()</script></body></html>');
214
- console.log(color('green', 'Login successful!'));
215
- settle();
194
+ function tryLogin(port) {
195
+ return new Promise((resolve, reject) => {
196
+ let settled = false;
197
+ let timeoutId = null;
198
+
199
+ const server = http.createServer((req, res) => {
200
+ const url = new URL(req.url, `http://localhost:${port}`);
201
+ if (url.pathname === '/callback') {
202
+ const returnedState = url.searchParams.get('state');
203
+ if (returnedState !== state) {
204
+ res.writeHead(403, { 'Content-Type': 'text/html' });
205
+ res.end('<html><body><h2>Login failed. Invalid state parameter (possible CSRF).</h2></body></html>');
206
+ settle(new Error('CSRF state mismatch'));
207
+ return;
208
+ }
209
+
210
+ const token = url.searchParams.get('token');
211
+ const refreshToken = url.searchParams.get('refreshToken');
212
+ if (token) {
213
+ const config = loadConfig();
214
+ const hadApiKey = Boolean(config.apiKey);
215
+ delete config.apiKey;
216
+ saveConfig({ ...config, token, refreshToken, baseUrl });
217
+ res.writeHead(200, { 'Content-Type': 'text/html' });
218
+ res.end('<html><body><h2>Logged in! You can close this tab.</h2><script>window.close()</script></body></html>');
219
+ console.log(color('green', 'Login successful!'));
220
+ if (hadApiKey) {
221
+ console.log('Switched to OAuth. Stored API key cleared.');
222
+ }
223
+ settle();
224
+ } else {
225
+ res.writeHead(400, { 'Content-Type': 'text/html' });
226
+ res.end('<html><body><h2>Login failed. No token received.</h2></body></html>');
227
+ settle(new Error('No token received'));
228
+ }
216
229
  } else {
217
- res.writeHead(400, { 'Content-Type': 'text/html' });
218
- res.end('<html><body><h2>Login failed. No token received.</h2></body></html>');
219
- settle(new Error('No token received'));
230
+ res.writeHead(404);
231
+ res.end();
232
+ }
233
+ });
234
+
235
+ const settle = (error = null) => {
236
+ if (settled) return;
237
+ settled = true;
238
+ if (timeoutId) clearTimeout(timeoutId);
239
+
240
+ const finish = () => {
241
+ if (error) {
242
+ console.log('\nLogin failed. Try API key auth instead:');
243
+ console.log(' 1. Sign in at https://usetrackly.app');
244
+ console.log(' 2. Go to Settings → API Keys → Create');
245
+ console.log(' 3. Run: trackly config --api-key <your-key>');
246
+ reject(error);
247
+ } else {
248
+ resolve();
249
+ }
250
+ };
251
+
252
+ if (server.listening) {
253
+ server.close(finish);
254
+ } else {
255
+ finish();
220
256
  }
221
- } else {
222
- res.writeHead(404);
223
- res.end();
224
- }
225
- });
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
257
  };
236
258
 
237
- if (server.listening) {
238
- server.close(finish);
239
- } else {
240
- finish();
241
- }
242
- };
259
+ server.on('error', (err) => {
260
+ if (err.code === 'EADDRINUSE') {
261
+ reject(err);
262
+ } else {
263
+ settle(err);
264
+ }
265
+ });
243
266
 
244
- server.on('error', settle);
245
- server.listen(port, '127.0.0.1', () => {
246
- const loginUrl = `${baseUrl}/auth/google/cli?port=${port}&state=${state}`;
247
- if (!openExternalUrl(loginUrl)) {
248
- console.log(`Open this URL in your browser:\n${loginUrl}`);
249
- }
267
+ server.listen(port, '127.0.0.1', () => {
268
+ console.log(color('dim', `Callback server on port ${port}`));
269
+ const loginUrl = `${baseUrl}/auth/google/cli?port=${port}&state=${state}`;
270
+ if (!openExternalUrl(loginUrl)) {
271
+ console.log(`Open this URL in your browser:\n${loginUrl}`);
272
+ }
273
+ });
274
+
275
+ timeoutId = setTimeout(() => {
276
+ settle(new Error('Login timed out'));
277
+ }, 300000);
250
278
  });
279
+ }
251
280
 
252
- timeoutId = setTimeout(() => {
253
- settle(new Error('Login timed out'));
254
- }, 120000);
255
- });
281
+ const MAX_PORT_RETRIES = 3;
282
+ for (let attempt = 0; attempt < MAX_PORT_RETRIES; attempt++) {
283
+ const port = 19847 + Math.floor(Math.random() * 1000);
284
+ try {
285
+ return await tryLogin(port);
286
+ } catch (err) {
287
+ if (err.code === 'EADDRINUSE' && attempt < MAX_PORT_RETRIES - 1) {
288
+ console.log(color('dim', `Port ${port} in use, trying another...`));
289
+ continue;
290
+ }
291
+ throw err;
292
+ }
293
+ }
256
294
  }
257
295
 
258
296
  async function cmdLogout() {
@@ -447,6 +485,109 @@ async function cmdApiKey(args) {
447
485
  }
448
486
  }
449
487
 
488
+ async function cmdContacts(args) {
489
+ if (!hasAuth()) failNow(AUTH_HELP_TEXT);
490
+
491
+ const params = new URLSearchParams();
492
+ const searchQuery = args.search || args._.join(' ').trim();
493
+ if (searchQuery) params.set('search', searchQuery);
494
+ if (args.limit) params.set('limit', args.limit);
495
+ if (args.offset) params.set('offset', args.offset);
496
+
497
+ const qs = params.toString();
498
+ try {
499
+ const result = await apiRequest('GET', `/api/network/people${qs ? '?' + qs : ''}`);
500
+ outputContacts(result.contacts || result.people || result.data || []);
501
+ if (!isJSON() && result.total) {
502
+ console.log(color('dim', `Showing ${(result.contacts || result.people || result.data || []).length} of ${result.total} contacts`));
503
+ }
504
+ } catch (e) {
505
+ setErrorExit(e.error || e.message || 'Failed to fetch contacts');
506
+ }
507
+ }
508
+
509
+ async function cmdReferralStart(args) {
510
+ const id = parseJobId(args._[0]);
511
+ if (id === null) failNow('Job ID must be a positive integer. Usage: trackly referral start <id>');
512
+ if (!hasAuth()) failNow(AUTH_HELP_TEXT);
513
+
514
+ try {
515
+ const result = await apiRequest('POST', `/api/network/jobs/${id}/referral-campaign`);
516
+ if (isJSON()) return printJson(result);
517
+ outputReferralCampaign(result.campaign || result);
518
+ } catch (e) {
519
+ setErrorExit(e.error || e.message || 'Failed to start referral campaign');
520
+ }
521
+ }
522
+
523
+ async function cmdReferralStatus(args) {
524
+ const id = parseJobId(args._[0]);
525
+ if (id === null) failNow('Job ID must be a positive integer. Usage: trackly referral status <id>');
526
+ if (!hasAuth()) failNow(AUTH_HELP_TEXT);
527
+
528
+ try {
529
+ const result = await apiRequest('GET', `/api/network/jobs/${id}/referral-campaign`);
530
+ if (isJSON()) return printJson(result);
531
+ outputReferralCampaign(result.campaign || result);
532
+ } catch (e) {
533
+ setErrorExit(e.error || e.message || 'Failed to fetch referral campaign status');
534
+ }
535
+ }
536
+
537
+ async function cmdBrief(args) {
538
+ const id = parseJobId(args._[0]);
539
+ if (id === null) failNow('Job ID must be a positive integer. Usage: trackly brief <id>');
540
+ if (!hasAuth()) failNow(AUTH_HELP_TEXT);
541
+
542
+ try {
543
+ const result = await apiRequest('GET', `/api/jobscout/jobs/${id}/network-brief`);
544
+ if (isJSON()) return printJson(result);
545
+ outputNetworkBrief(result.brief || result);
546
+ } catch (e) {
547
+ setErrorExit(e.error || e.message || 'Failed to fetch network brief');
548
+ }
549
+ }
550
+
551
+ async function cmdCompanyBrief(args) {
552
+ const id = parseJobId(args._[0]);
553
+ if (id === null) failNow('Company ID must be a positive integer. Usage: trackly company-brief <companyId> [--refresh]');
554
+ if (!hasAuth()) failNow(AUTH_HELP_TEXT);
555
+
556
+ try {
557
+ if (args.refresh) {
558
+ const result = await apiRequest('POST', `/api/network/companies/${id}/brief/refresh`);
559
+ if (isJSON()) return printJson(result);
560
+ console.log(color('green', 'Company brief refresh triggered.'));
561
+ outputCompanyBrief(result.brief || result);
562
+ } else {
563
+ const result = await apiRequest('GET', `/api/network/companies/${id}/brief`);
564
+ if (isJSON()) return printJson(result);
565
+ const brief = result.brief || result;
566
+ if (!brief || (typeof brief === 'object' && Object.keys(brief).length === 0)) {
567
+ console.log(color('dim', 'No brief available. Use --refresh to generate.'));
568
+ } else {
569
+ outputCompanyBrief(brief);
570
+ }
571
+ }
572
+ } catch (e) {
573
+ setErrorExit(e.error || e.message || 'Failed to fetch company brief');
574
+ }
575
+ }
576
+
577
+ async function cmdCompanyWorkspace(args) {
578
+ const id = parseJobId(args._[0]);
579
+ if (id === null) failNow('Company ID must be a positive integer. Usage: trackly company-workspace <companyId>');
580
+ if (!hasAuth()) failNow(AUTH_HELP_TEXT);
581
+
582
+ try {
583
+ const result = await apiRequest('GET', `/api/network/companies/${id}/workspace`);
584
+ if (isJSON()) return printJson(result);
585
+ outputCompanyWorkspace(result.workspace || result);
586
+ } catch (e) {
587
+ setErrorExit(e.error || e.message || 'Failed to fetch company workspace');
588
+ }
589
+ }
590
+
450
591
  async function cmdConfig(args) {
451
592
  if (args['api-key'] === null) failNow('API key value required. Usage: trackly config --api-key <key>');
452
593
  if (args['base-url'] === null) failNow('Base URL value required. Usage: trackly config --base-url <url>');
@@ -467,9 +608,19 @@ async function cmdConfig(args) {
467
608
  }
468
609
 
469
610
  if (args['api-key'] !== undefined) {
470
- config.apiKey = String(args['api-key']).trim();
611
+ const keyValue = String(args['api-key']).trim();
612
+ if (!keyValue.startsWith('trk_') || keyValue.length < 10) {
613
+ console.log(color('yellow', 'Warning: API key does not look like a Trackly key (expected trk_... prefix). Saving anyway.'));
614
+ }
615
+ const hadToken = Boolean(config.token);
616
+ config.apiKey = keyValue;
617
+ delete config.token;
618
+ delete config.refreshToken;
471
619
  updated = true;
472
- console.log(color('green', 'API key saved. It will take precedence over OAuth tokens.'));
620
+ console.log(color('green', 'API key saved.'));
621
+ if (hadToken) {
622
+ console.log('Switched to API key auth. OAuth token cleared.');
623
+ }
473
624
  }
474
625
 
475
626
  if (args['base-url'] !== undefined) {
@@ -528,6 +679,13 @@ ${color('bold', 'Commands:')}
528
679
  ${color('cyan', 'save <id>')} Save a job
529
680
  ${color('cyan', 'dismiss <id>')} Dismiss a job
530
681
  ${color('cyan', 'ask "query"')} Natural language job search (20/day)
682
+ ${color('cyan', 'contacts')} List your contacts (--search, --limit, --offset)
683
+ ${color('cyan', 'contacts search <q>')} Search contacts by name, company, or title
684
+ ${color('cyan', 'referral start <id>')} Start a referral campaign for a job
685
+ ${color('cyan', 'referral status <id>')} Check referral campaign progress
686
+ ${color('cyan', 'brief <id>')} Get network brief for a job
687
+ ${color('cyan', 'company-brief <id>')} Get company brief (--refresh to generate)
688
+ ${color('cyan', 'company-workspace <id>')} Show company workspace (jobs, contacts, campaigns)
531
689
  ${color('cyan', 'api-key create')} Generate an API key
532
690
  ${color('cyan', 'api-key list')} List your API keys
533
691
  ${color('cyan', 'config')} Show or update CLI config (--api-key, --base-url)
@@ -624,6 +782,31 @@ async function main() {
624
782
  case 'ask':
625
783
  await cmdAsk(args);
626
784
  break;
785
+ case 'contacts':
786
+ if (args._[0] === 'search') {
787
+ await cmdContacts({ ...args, _: [], search: args._.slice(1).join(' ') });
788
+ } else {
789
+ await cmdContacts(args);
790
+ }
791
+ break;
792
+ case 'referral':
793
+ if (args._[0] === 'start') {
794
+ await cmdReferralStart({ ...args, _: args._.slice(1) });
795
+ } else if (args._[0] === 'status') {
796
+ await cmdReferralStatus({ ...args, _: args._.slice(1) });
797
+ } else {
798
+ failNow('Unknown referral subcommand. Use: trackly referral start <id> | trackly referral status <id>');
799
+ }
800
+ break;
801
+ case 'brief':
802
+ await cmdBrief(args);
803
+ break;
804
+ case 'company-brief':
805
+ await cmdCompanyBrief(args);
806
+ break;
807
+ case 'company-workspace':
808
+ await cmdCompanyWorkspace(args);
809
+ break;
627
810
  case 'api-key':
628
811
  await cmdApiKey(args);
629
812
  break;
@@ -59,6 +59,9 @@ You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
59
59
  - **trackly_get_stats** — Job tracker metrics dashboard
60
60
  - **trackly_update_status** — Mark a job as applied, saved, or dismissed
61
61
  - **trackly_ask** — Natural language job search (20/day limit)
62
+ - **trackly_get_job_brief** — Get network brief for a job (company signal, top contact, actions)
63
+ - **trackly_contacts_at_company** — Search contacts at a specific company
64
+ - **trackly_get_company_workspace** — Get full company workspace (jobs, contacts, hiring managers, campaigns)
62
65
 
63
66
  ### Example Prompts
64
67
 
package/lib/client.js CHANGED
@@ -68,7 +68,7 @@ function getBaseUrl() {
68
68
  }
69
69
 
70
70
  function getApiKey() {
71
- return process.env.TRACKLY_API_KEY || loadConfig().apiKey || null;
71
+ return (process.env.TRACKLY_API_KEY || loadConfig().apiKey || '').trim() || null;
72
72
  }
73
73
 
74
74
  function getToken() {
package/lib/formatters.js CHANGED
@@ -14,6 +14,24 @@ function isJSON() {
14
14
  return !process.stdout.isTTY || process.argv.includes('--json');
15
15
  }
16
16
 
17
+ function formatFundingLine(company) {
18
+ if (!company) return null;
19
+ const parts = [];
20
+ const series = company.fundingSeries || company.fundingStage;
21
+ if (series) {
22
+ let badge = series;
23
+ if (company.valuationMillions) {
24
+ const val = company.valuationMillions;
25
+ badge += ` · ${val >= 1000 ? `$${(val / 1000).toFixed(val >= 10000 ? 0 : 1)}B` : `$${val.toFixed(0)}M`}`;
26
+ }
27
+ parts.push(badge);
28
+ }
29
+ if (company.fundingStage && company.fundingSeries) {
30
+ parts.push(company.fundingStage);
31
+ }
32
+ return parts.length > 0 ? parts.join(' | ') : null;
33
+ }
34
+
17
35
  function outputJobs(jobs) {
18
36
  if (isJSON()) return console.log(JSON.stringify(jobs, null, 2));
19
37
  if (!jobs || jobs.length === 0) {
@@ -25,6 +43,8 @@ function outputJobs(jobs) {
25
43
  const location = job.location ? color('dim', job.location) : '';
26
44
  console.log(`${color('bold', job.title)} ${modality}`);
27
45
  console.log(` ${color('blue', job.company_name)} | ${location}`);
46
+ const fundingLine = formatFundingLine(job.company);
47
+ if (fundingLine) console.log(` ${color('magenta', fundingLine)}`);
28
48
  console.log(` ID: ${color('dim', job.id)} | Posted: ${color('dim', job.posted_at || job.first_seen_at || 'unknown')}`);
29
49
  if (job.url) console.log(` ${color('dim', job.url)}`);
30
50
  console.log();
@@ -61,8 +81,240 @@ function outputStats(stats) {
61
81
  }
62
82
  }
63
83
 
84
+ function outputContacts(contacts) {
85
+ if (isJSON()) return console.log(JSON.stringify(contacts, null, 2));
86
+ if (!contacts || contacts.length === 0) {
87
+ console.log(color('dim', 'No contacts found.'));
88
+ return;
89
+ }
90
+ // Header
91
+ console.log(
92
+ color('bold', padRight('Name', 24)) + ' ' +
93
+ color('bold', padRight('Title', 28)) + ' ' +
94
+ color('bold', padRight('Company', 20)) + ' ' +
95
+ color('bold', padRight('Email', 28)) + ' ' +
96
+ color('bold', 'Status')
97
+ );
98
+ console.log(color('dim', '-'.repeat(110)));
99
+ for (const contact of contacts) {
100
+ const name = padRight(contact.name || '', 24);
101
+ const title = padRight(contact.title || '', 28);
102
+ const company = padRight(contact.company || '', 20);
103
+ const email = padRight(contact.email || '', 28);
104
+ const status = contact.status || '';
105
+ const statusColor = status === 'active' ? 'green' : status === 'pending' ? 'yellow' : 'dim';
106
+ console.log(
107
+ `${name} ${title} ${company} ${email} ${color(statusColor, status)}`
108
+ );
109
+ }
110
+ }
111
+
112
+ function outputReferralCampaign(campaign) {
113
+ if (isJSON()) return console.log(JSON.stringify(campaign, null, 2));
114
+ if (!campaign) {
115
+ console.log(color('dim', 'No campaign data.'));
116
+ return;
117
+ }
118
+ console.log(color('bold', 'Referral Campaign'));
119
+ console.log();
120
+ if (campaign.id) console.log(`Campaign ID: ${color('cyan', campaign.id)}`);
121
+ if (campaign.jobId) console.log(`Job ID: ${color('dim', campaign.jobId)}`);
122
+ if (campaign.status) {
123
+ const statusColor = campaign.status === 'active' ? 'green' : campaign.status === 'completed' ? 'blue' : 'yellow';
124
+ console.log(`Status: ${color(statusColor, campaign.status)}`);
125
+ }
126
+ if (campaign.createdAt) console.log(`Created: ${color('dim', campaign.createdAt)}`);
127
+ if (campaign.company) console.log(`Company: ${color('blue', campaign.company)}`);
128
+ if (campaign.jobTitle) console.log(`Job: ${color('bold', campaign.jobTitle)}`);
129
+
130
+ if (campaign.contacts && campaign.contacts.length > 0) {
131
+ console.log();
132
+ console.log(color('bold', 'Outreach Progress:'));
133
+ for (const c of campaign.contacts) {
134
+ const icon = c.status === 'sent' ? color('green', '[sent]') :
135
+ c.status === 'replied' ? color('cyan', '[replied]') :
136
+ c.status === 'pending' ? color('yellow', '[pending]') :
137
+ color('dim', `[${c.status || 'unknown'}]`);
138
+ console.log(` ${icon} ${c.name || 'Unknown'}${c.title ? ' — ' + color('dim', c.title) : ''}`);
139
+ }
140
+ }
141
+
142
+ // Print any other top-level keys
143
+ for (const [k, v] of Object.entries(campaign)) {
144
+ if (!['id', 'jobId', 'status', 'createdAt', 'company', 'jobTitle', 'contacts'].includes(k)) {
145
+ console.log(`${k}: ${color('dim', JSON.stringify(v))}`);
146
+ }
147
+ }
148
+ }
149
+
150
+ function outputNetworkBrief(brief) {
151
+ if (isJSON()) return console.log(JSON.stringify(brief, null, 2));
152
+ if (!brief) {
153
+ console.log(color('dim', 'No network brief available.'));
154
+ return;
155
+ }
156
+ console.log(color('bold', 'Network Brief'));
157
+ console.log();
158
+
159
+ if (brief.companySignal) {
160
+ console.log(color('bold', 'Company Signal:'));
161
+ console.log(` ${brief.companySignal}`);
162
+ console.log();
163
+ }
164
+
165
+ if (brief.recommendedMotion) {
166
+ console.log(color('bold', 'Recommended Motion:'));
167
+ console.log(` ${color('cyan', brief.recommendedMotion)}`);
168
+ console.log();
169
+ }
170
+
171
+ if (brief.topContact) {
172
+ console.log(color('bold', 'Top Contact:'));
173
+ const tc = brief.topContact;
174
+ if (tc.name) console.log(` Name: ${tc.name}`);
175
+ if (tc.title) console.log(` Title: ${color('dim', tc.title)}`);
176
+ if (tc.company) console.log(` Company: ${color('blue', tc.company)}`);
177
+ if (tc.email) console.log(` Email: ${color('cyan', tc.email)}`);
178
+ if (tc.reason) console.log(` Why: ${color('dim', tc.reason)}`);
179
+ console.log();
180
+ }
181
+
182
+ if (brief.actions && brief.actions.length > 0) {
183
+ console.log(color('bold', 'Actions:'));
184
+ for (let i = 0; i < brief.actions.length; i++) {
185
+ const action = brief.actions[i];
186
+ if (typeof action === 'string') {
187
+ console.log(` ${color('green', (i + 1) + '.')} ${action}`);
188
+ } else {
189
+ console.log(` ${color('green', (i + 1) + '.')} ${action.description || JSON.stringify(action)}`);
190
+ }
191
+ }
192
+ console.log();
193
+ }
194
+
195
+ // Print any other top-level keys
196
+ for (const [k, v] of Object.entries(brief)) {
197
+ if (!['companySignal', 'recommendedMotion', 'topContact', 'actions'].includes(k)) {
198
+ console.log(`${k}: ${color('dim', JSON.stringify(v))}`);
199
+ }
200
+ }
201
+ }
202
+
203
+ function outputCompanyBrief(brief) {
204
+ if (isJSON()) return console.log(JSON.stringify(brief, null, 2));
205
+ if (!brief) {
206
+ console.log(color('dim', 'No brief available. Use --refresh to generate.'));
207
+ return;
208
+ }
209
+ console.log(color('bold', 'Company Brief'));
210
+ console.log();
211
+
212
+ if (brief.companyName) console.log(`Company: ${color('blue', brief.companyName)}`);
213
+ if (brief.summary) {
214
+ console.log();
215
+ console.log(color('bold', 'Summary:'));
216
+ console.log(` ${brief.summary}`);
217
+ }
218
+ if (brief.hiringTrends) {
219
+ console.log();
220
+ console.log(color('bold', 'Hiring Trends:'));
221
+ console.log(` ${brief.hiringTrends}`);
222
+ }
223
+ if (brief.recentNews) {
224
+ console.log();
225
+ console.log(color('bold', 'Recent News:'));
226
+ if (Array.isArray(brief.recentNews)) {
227
+ for (const item of brief.recentNews) {
228
+ console.log(` - ${typeof item === 'string' ? item : item.headline || JSON.stringify(item)}`);
229
+ }
230
+ } else {
231
+ console.log(` ${brief.recentNews}`);
232
+ }
233
+ }
234
+ if (brief.keyPeople && brief.keyPeople.length > 0) {
235
+ console.log();
236
+ console.log(color('bold', 'Key People:'));
237
+ for (const person of brief.keyPeople) {
238
+ console.log(` ${person.name || 'Unknown'}${person.title ? ' — ' + color('dim', person.title) : ''}`);
239
+ }
240
+ }
241
+ if (brief.generatedAt) console.log(color('dim', `\nGenerated: ${brief.generatedAt}`));
242
+
243
+ // Print any other top-level keys
244
+ for (const [k, v] of Object.entries(brief)) {
245
+ if (!['companyName', 'summary', 'hiringTrends', 'recentNews', 'keyPeople', 'generatedAt'].includes(k)) {
246
+ console.log(`${k}: ${color('dim', JSON.stringify(v))}`);
247
+ }
248
+ }
249
+ }
250
+
251
+ function outputCompanyWorkspace(workspace) {
252
+ if (isJSON()) return console.log(JSON.stringify(workspace, null, 2));
253
+ if (!workspace) {
254
+ console.log(color('dim', 'No workspace data available.'));
255
+ return;
256
+ }
257
+ console.log(color('bold', 'Company Workspace'));
258
+ console.log();
259
+
260
+ if (workspace.companyName) console.log(`Company: ${color('blue', workspace.companyName)}`);
261
+ if (workspace.activeJobsCount !== undefined) console.log(`Active Jobs: ${color('cyan', workspace.activeJobsCount)}`);
262
+ if (workspace.contactsCount !== undefined) console.log(`Contacts: ${color('cyan', workspace.contactsCount)}`);
263
+
264
+ if (workspace.hiringManagers && workspace.hiringManagers.length > 0) {
265
+ console.log();
266
+ console.log(color('bold', 'Hiring Managers:'));
267
+ for (const hm of workspace.hiringManagers) {
268
+ console.log(` ${hm.name || 'Unknown'}${hm.title ? ' — ' + color('dim', hm.title) : ''}`);
269
+ if (hm.email) console.log(` ${color('cyan', hm.email)}`);
270
+ }
271
+ }
272
+
273
+ if (workspace.coverageGap !== undefined) {
274
+ console.log();
275
+ console.log(color('bold', 'Coverage Gap:'));
276
+ if (typeof workspace.coverageGap === 'string') {
277
+ console.log(` ${workspace.coverageGap}`);
278
+ } else {
279
+ console.log(` ${JSON.stringify(workspace.coverageGap)}`);
280
+ }
281
+ }
282
+
283
+ if (workspace.campaignStatus) {
284
+ console.log();
285
+ console.log(color('bold', 'Campaign Status:'));
286
+ if (typeof workspace.campaignStatus === 'string') {
287
+ const statusColor = workspace.campaignStatus === 'active' ? 'green' : workspace.campaignStatus === 'completed' ? 'blue' : 'yellow';
288
+ console.log(` ${color(statusColor, workspace.campaignStatus)}`);
289
+ } else {
290
+ console.log(` ${JSON.stringify(workspace.campaignStatus)}`);
291
+ }
292
+ }
293
+
294
+ if (workspace.contacts && workspace.contacts.length > 0) {
295
+ console.log();
296
+ console.log(color('bold', 'Contacts:'));
297
+ for (const contact of workspace.contacts) {
298
+ const statusColor = contact.status === 'active' ? 'green' : contact.status === 'pending' ? 'yellow' : 'dim';
299
+ console.log(` ${contact.name || 'Unknown'}${contact.title ? ' — ' + color('dim', contact.title) : ''} ${color(statusColor, contact.status || '')}`);
300
+ }
301
+ }
302
+
303
+ // Print any other top-level keys
304
+ for (const [k, v] of Object.entries(workspace)) {
305
+ if (!['companyName', 'activeJobsCount', 'contactsCount', 'hiringManagers', 'coverageGap', 'campaignStatus', 'contacts'].includes(k)) {
306
+ console.log(`${k}: ${color('dim', JSON.stringify(v))}`);
307
+ }
308
+ }
309
+ }
310
+
311
+ function padRight(str, len) {
312
+ if (str.length >= len) return str.slice(0, len);
313
+ return str + ' '.repeat(len - str.length);
314
+ }
315
+
64
316
  function outputError(msg) {
65
317
  console.error(color('red', `Error: ${msg}`));
66
318
  }
67
319
 
68
- module.exports = { color, isJSON, outputJobs, outputCompanies, outputStats, outputError };
320
+ module.exports = { color, isJSON, outputJobs, outputCompanies, outputStats, outputContacts, outputReferralCampaign, outputNetworkBrief, outputCompanyBrief, outputCompanyWorkspace, outputError };
package/mcp/server.js CHANGED
@@ -7,7 +7,7 @@ const { apiRequest, hasAuth } = require('../lib/client');
7
7
  const { version: PACKAGE_VERSION } = require('../package.json');
8
8
 
9
9
  const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
10
- const AUTH_HINT = 'Run `trackly login` or configure an API key with `trackly config --api-key <key>`.';
10
+ const AUTH_HINT = 'Run `trackly login` or set TRACKLY_API_KEY. Get a key at https://usetrackly.app (sign in → Settings → API Keys).';
11
11
 
12
12
  function createErrorResult(error, fallbackMessage, extra = {}) {
13
13
  const payload = {
@@ -166,6 +166,42 @@ function createServer() {
166
166
  }, 'Failed to process natural language query')
167
167
  );
168
168
 
169
+ server.tool(
170
+ 'trackly_get_job_brief',
171
+ 'Get a network brief for a specific job. Returns company signal, recommended motion, top contact, and suggested actions.',
172
+ {
173
+ jobId: z.number().describe('Job posting ID'),
174
+ },
175
+ wrapTool(async ({ jobId }) => {
176
+ return apiRequest('GET', `/api/jobscout/jobs/${jobId}/network-brief`, null, false, false, MCP_USER_AGENT);
177
+ }, 'Failed to fetch network brief')
178
+ );
179
+
180
+ server.tool(
181
+ 'trackly_contacts_at_company',
182
+ 'Search contacts at a specific company. Returns matching contacts with name, title, email, and status.',
183
+ {
184
+ company: z.string().max(200).describe('Company name to search contacts for'),
185
+ limit: z.number().max(50).optional().describe('Max results (default 20)'),
186
+ },
187
+ wrapTool(async ({ company, limit }) => {
188
+ const qs = new URLSearchParams({ search: company });
189
+ if (limit) qs.set('limit', String(limit));
190
+ return apiRequest('GET', `/api/network/people?${qs.toString()}`, null, false, false, MCP_USER_AGENT);
191
+ }, 'Failed to search contacts at company')
192
+ );
193
+
194
+ server.tool(
195
+ 'trackly_get_company_workspace',
196
+ 'Get the full workspace view for a company: active jobs, contacts, hiring managers, coverage gap, and campaign status.',
197
+ {
198
+ companyId: z.number().describe('Company ID'),
199
+ },
200
+ wrapTool(async ({ companyId }) => {
201
+ return apiRequest('GET', `/api/network/companies/${companyId}/workspace`, null, false, false, MCP_USER_AGENT);
202
+ }, 'Failed to fetch company workspace')
203
+ );
204
+
169
205
  return server;
170
206
  }
171
207
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "mcpName": "io.github.kevinastuhuaman/trackly",
5
5
  "description": "CLI + MCP server for job tracking — search, apply, and manage applications from terminal or AI agents",
6
6
  "bin": {
@@ -46,7 +46,7 @@
46
46
  "url": "https://github.com/kevinastuhuaman/trackly-cli/issues"
47
47
  },
48
48
  "dependencies": {
49
- "@modelcontextprotocol/sdk": "^1.12.1",
49
+ "@modelcontextprotocol/sdk": "^1.27.1",
50
50
  "zod": "^3.23.0"
51
51
  },
52
52
  "engines": {
package/server.json CHANGED
@@ -15,12 +15,12 @@
15
15
  "sizes": ["512x512"]
16
16
  }
17
17
  ],
18
- "version": "0.1.4",
18
+ "version": "0.1.5",
19
19
  "packages": [
20
20
  {
21
21
  "registryType": "npm",
22
22
  "identifier": "trackly-cli",
23
- "version": "0.1.4",
23
+ "version": "0.1.5",
24
24
  "runtimeHint": "npx",
25
25
  "runtimeArguments": [
26
26
  {