trackly-cli 0.1.5 → 0.1.7
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 +30 -12
- package/bin/trackly +247 -64
- package/docs/trackly-tools.md +3 -0
- package/lib/client.js +1 -1
- package/lib/formatters.js +253 -1
- package/mcp/server.js +48 -3
- package/package.json +1 -1
- package/server.json +2 -2
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
|
-
##
|
|
108
|
+
## Authentication
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
### Option 1: Google OAuth (recommended)
|
|
111
111
|
|
|
112
112
|
```bash
|
|
113
|
-
trackly
|
|
113
|
+
trackly login
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
-
|
|
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
|
-
|
|
132
|
+
```bash
|
|
123
133
|
trackly --api-key trk_xxxxxxxxxxxxxxxxxxxx jobs --json
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Or set it as an environment variable:
|
|
124
137
|
|
|
125
|
-
|
|
126
|
-
TRACKLY_API_KEY=trk_xxxxxxxxxxxxxxxxxxxx
|
|
138
|
+
```bash
|
|
139
|
+
export TRACKLY_API_KEY=trk_xxxxxxxxxxxxxxxxxxxx
|
|
140
|
+
trackly jobs
|
|
127
141
|
```
|
|
128
142
|
|
|
129
|
-
|
|
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
|
|
148
|
+
trackly api-key create --name "my-script"
|
|
149
|
+
trackly api-key list
|
|
133
150
|
```
|
|
134
151
|
|
|
135
|
-
|
|
152
|
+
### Other config
|
|
136
153
|
|
|
137
154
|
```bash
|
|
138
|
-
trackly config --
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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(
|
|
218
|
-
res.end(
|
|
219
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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.
|
|
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;
|
package/docs/trackly-tools.md
CHANGED
|
@@ -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
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
|
|
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 = {
|
|
@@ -66,9 +66,10 @@ function createServer() {
|
|
|
66
66
|
|
|
67
67
|
server.tool(
|
|
68
68
|
'trackly_search_jobs',
|
|
69
|
-
'Search and filter job postings. Returns matching jobs with title, company, location, modality.',
|
|
69
|
+
'Search and filter job postings. Returns matching jobs with title, company, location, modality. Use companyId to filter jobs at a specific company (get companyId from trackly_search_companies first).',
|
|
70
70
|
{
|
|
71
71
|
function: z.enum(['product_management', 'engineering', 'design', 'data_science', 'marketing', 'sales', 'finance', 'operations', 'legal', 'hr', 'other']).optional().describe('Job function filter: product_management, engineering, design, data_science, marketing, sales, finance, operations, legal, hr, other'),
|
|
72
|
+
companyId: z.number().optional().describe('Filter jobs by company ID (get from trackly_search_companies)'),
|
|
72
73
|
location: z.string().optional().describe('Location filter (city or state)'),
|
|
73
74
|
modality: z.enum(['remote', 'hybrid', 'onsite']).optional().describe('Work modality: remote, hybrid, onsite'),
|
|
74
75
|
status: z.enum(['new', 'saved', 'applied', 'dismissed']).optional().describe('Application status: new, saved, applied, dismissed'),
|
|
@@ -78,9 +79,17 @@ function createServer() {
|
|
|
78
79
|
keywords: z.string().max(500).optional().describe('Keyword search in title/description'),
|
|
79
80
|
},
|
|
80
81
|
wrapTool(async (params) => {
|
|
82
|
+
// Map MCP param names to backend query param names
|
|
83
|
+
const paramMap = {
|
|
84
|
+
function: 'jobFunction',
|
|
85
|
+
keywords: 'search',
|
|
86
|
+
};
|
|
81
87
|
const qs = new URLSearchParams();
|
|
82
88
|
for (const [key, value] of Object.entries(params)) {
|
|
83
|
-
if (value !== undefined && value !== null)
|
|
89
|
+
if (value !== undefined && value !== null) {
|
|
90
|
+
const backendKey = paramMap[key] || key;
|
|
91
|
+
qs.set(backendKey, String(value));
|
|
92
|
+
}
|
|
84
93
|
}
|
|
85
94
|
return apiRequest('GET', `/api/jobscout/jobs?${qs.toString()}`, null, false, false, MCP_USER_AGENT);
|
|
86
95
|
}, 'Failed to search jobs')
|
|
@@ -166,6 +175,42 @@ function createServer() {
|
|
|
166
175
|
}, 'Failed to process natural language query')
|
|
167
176
|
);
|
|
168
177
|
|
|
178
|
+
server.tool(
|
|
179
|
+
'trackly_get_job_brief',
|
|
180
|
+
'Get a network brief for a specific job. Returns company signal, recommended motion, top contact, and suggested actions.',
|
|
181
|
+
{
|
|
182
|
+
jobId: z.number().describe('Job posting ID'),
|
|
183
|
+
},
|
|
184
|
+
wrapTool(async ({ jobId }) => {
|
|
185
|
+
return apiRequest('GET', `/api/jobscout/jobs/${jobId}/network-brief`, null, false, false, MCP_USER_AGENT);
|
|
186
|
+
}, 'Failed to fetch network brief')
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
server.tool(
|
|
190
|
+
'trackly_contacts_at_company',
|
|
191
|
+
'Search contacts at a specific company. Returns matching contacts with name, title, email, and status.',
|
|
192
|
+
{
|
|
193
|
+
company: z.string().max(200).describe('Company name to search contacts for'),
|
|
194
|
+
limit: z.number().max(50).optional().describe('Max results (default 20)'),
|
|
195
|
+
},
|
|
196
|
+
wrapTool(async ({ company, limit }) => {
|
|
197
|
+
const qs = new URLSearchParams({ search: company });
|
|
198
|
+
if (limit) qs.set('limit', String(limit));
|
|
199
|
+
return apiRequest('GET', `/api/network/people?${qs.toString()}`, null, false, false, MCP_USER_AGENT);
|
|
200
|
+
}, 'Failed to search contacts at company')
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
server.tool(
|
|
204
|
+
'trackly_get_company_workspace',
|
|
205
|
+
'Get the full workspace view for a company: active jobs, contacts, hiring managers, coverage gap, and campaign status.',
|
|
206
|
+
{
|
|
207
|
+
companyId: z.number().describe('Company ID'),
|
|
208
|
+
},
|
|
209
|
+
wrapTool(async ({ companyId }) => {
|
|
210
|
+
return apiRequest('GET', `/api/network/companies/${companyId}/workspace`, null, false, false, MCP_USER_AGENT);
|
|
211
|
+
}, 'Failed to fetch company workspace')
|
|
212
|
+
);
|
|
213
|
+
|
|
169
214
|
return server;
|
|
170
215
|
}
|
|
171
216
|
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
"sizes": ["512x512"]
|
|
16
16
|
}
|
|
17
17
|
],
|
|
18
|
-
"version": "0.1.
|
|
18
|
+
"version": "0.1.7",
|
|
19
19
|
"packages": [
|
|
20
20
|
{
|
|
21
21
|
"registryType": "npm",
|
|
22
22
|
"identifier": "trackly-cli",
|
|
23
|
-
"version": "0.1.
|
|
23
|
+
"version": "0.1.7",
|
|
24
24
|
"runtimeHint": "npx",
|
|
25
25
|
"runtimeArguments": [
|
|
26
26
|
{
|