trackly-cli 0.1.0
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 +71 -0
- package/bin/trackly +334 -0
- package/docs/trackly-tools.md +31 -0
- package/lib/client.js +124 -0
- package/lib/formatters.js +68 -0
- package/mcp/server.js +163 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @trackly/cli
|
|
2
|
+
|
|
3
|
+
Trackly job tracker CLI + MCP server for AI agents.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @trackly/cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Login
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
trackly login
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Opens Google OAuth in your browser. Credentials are stored in `~/.trackly/config.json`.
|
|
18
|
+
|
|
19
|
+
## CLI Usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
trackly jobs # List jobs
|
|
23
|
+
trackly jobs --modality remote # Filter remote jobs
|
|
24
|
+
trackly jobs --function engineering # Filter by function
|
|
25
|
+
trackly job 1234 # Get job details
|
|
26
|
+
trackly companies # List companies
|
|
27
|
+
trackly search "fintech" # Semantic company search
|
|
28
|
+
trackly stats # Show metrics
|
|
29
|
+
trackly apply 1234 # Mark as applied
|
|
30
|
+
trackly save 1234 # Save a job
|
|
31
|
+
trackly dismiss 1234 # Dismiss a job
|
|
32
|
+
trackly ask "PM jobs in SF" # Natural language search (20/day)
|
|
33
|
+
trackly api-key create # Generate API key
|
|
34
|
+
trackly api-key list # List API keys
|
|
35
|
+
trackly whoami # Show current user
|
|
36
|
+
trackly logout # Clear credentials
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Add `--json` to any command for JSON output.
|
|
40
|
+
|
|
41
|
+
## MCP Server (Claude Code)
|
|
42
|
+
|
|
43
|
+
Add to `~/.claude/settings.json`:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"mcpServers": {
|
|
48
|
+
"trackly": {
|
|
49
|
+
"command": "trackly",
|
|
50
|
+
"args": ["mcp"]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Then use natural language in Claude Code:
|
|
57
|
+
- "Find me PM jobs at fintech companies"
|
|
58
|
+
- "What remote engineering roles are available?"
|
|
59
|
+
- "Mark job 1234 as applied"
|
|
60
|
+
|
|
61
|
+
## API Key
|
|
62
|
+
|
|
63
|
+
For programmatic access without OAuth:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
trackly api-key create --name "my-script"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
package/bin/trackly
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { loadConfig, saveConfig, clearConfig, apiRequest, getToken, CONFIG_DIR } = require('../lib/client');
|
|
5
|
+
const { color, isJSON, outputJobs, outputCompanies, outputStats, outputError } = require('../lib/formatters');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const { execSync } = require('child_process');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
function parseArgs(args) {
|
|
12
|
+
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) {
|
|
20
|
+
const arg = args[i];
|
|
21
|
+
if (arg.startsWith('--')) {
|
|
22
|
+
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++; }
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// -- Commands --
|
|
32
|
+
|
|
33
|
+
async function cmdLogin() {
|
|
34
|
+
const port = 19847 + Math.floor(Math.random() * 1000);
|
|
35
|
+
const baseUrl = loadConfig().baseUrl || 'https://closeai.mba';
|
|
36
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
37
|
+
|
|
38
|
+
console.log(color('cyan', 'Opening browser for Google login...'));
|
|
39
|
+
console.log(color('dim', `Callback server on port ${port}`));
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const server = http.createServer((req, res) => {
|
|
43
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
44
|
+
if (url.pathname === '/callback') {
|
|
45
|
+
const returnedState = url.searchParams.get('state');
|
|
46
|
+
if (returnedState !== state) {
|
|
47
|
+
res.writeHead(403, { 'Content-Type': 'text/html' });
|
|
48
|
+
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'));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const token = url.searchParams.get('token');
|
|
54
|
+
const refreshToken = url.searchParams.get('refreshToken');
|
|
55
|
+
if (token) {
|
|
56
|
+
saveConfig({ ...loadConfig(), token, refreshToken, baseUrl });
|
|
57
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
58
|
+
res.end('<html><body><h2>Logged in! You can close this tab.</h2><script>window.close()</script></body></html>');
|
|
59
|
+
console.log(color('green', 'Login successful!'));
|
|
60
|
+
server.close();
|
|
61
|
+
resolve();
|
|
62
|
+
} else {
|
|
63
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
64
|
+
res.end('<html><body><h2>Login failed. No token received.</h2></body></html>');
|
|
65
|
+
server.close();
|
|
66
|
+
reject(new Error('No token received'));
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
res.writeHead(404);
|
|
70
|
+
res.end();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
server.listen(port, '127.0.0.1', () => {
|
|
75
|
+
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 {
|
|
81
|
+
console.log(`Open this URL in your browser:\n${loginUrl}`);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Timeout after 2 minutes
|
|
86
|
+
setTimeout(() => { server.close(); reject(new Error('Login timed out')); }, 120000);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function cmdLogout() {
|
|
91
|
+
clearConfig();
|
|
92
|
+
console.log(color('green', 'Logged out. Credentials cleared.'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function cmdWhoami() {
|
|
96
|
+
if (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
|
|
97
|
+
try {
|
|
98
|
+
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)}`);
|
|
104
|
+
} else {
|
|
105
|
+
outputError('Not authenticated. Run: trackly login');
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
outputError(e.error || e.message || 'Failed to get user info');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function cmdJobs(args) {
|
|
113
|
+
if (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
|
|
114
|
+
const params = new URLSearchParams();
|
|
115
|
+
if (args.function) params.set('function', args.function);
|
|
116
|
+
if (args.location) params.set('location', args.location);
|
|
117
|
+
if (args.modality) params.set('modality', args.modality);
|
|
118
|
+
if (args.status) params.set('status', args.status);
|
|
119
|
+
if (args.sort) params.set('sort', args.sort);
|
|
120
|
+
if (args.limit) params.set('limit', args.limit);
|
|
121
|
+
if (args.offset) params.set('offset', args.offset);
|
|
122
|
+
if (args.keywords) params.set('keywords', args.keywords);
|
|
123
|
+
|
|
124
|
+
const qs = params.toString();
|
|
125
|
+
try {
|
|
126
|
+
const result = await apiRequest('GET', `/api/jobscout/jobs${qs ? '?' + qs : ''}`);
|
|
127
|
+
outputJobs(result.jobs || result.data || []);
|
|
128
|
+
if (!isJSON() && result.total) console.log(color('dim', `Showing ${(result.jobs || result.data || []).length} of ${result.total} jobs`));
|
|
129
|
+
} catch (e) {
|
|
130
|
+
outputError(e.error || e.message || 'Failed to fetch jobs');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
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); }
|
|
138
|
+
try {
|
|
139
|
+
const result = await apiRequest('GET', `/api/jobscout/jobs/${id}`);
|
|
140
|
+
if (isJSON()) return console.log(JSON.stringify(result, null, 2));
|
|
141
|
+
const job = result.job || result;
|
|
142
|
+
console.log(color('bold', job.title));
|
|
143
|
+
console.log(`Company: ${color('blue', job.company_name)}`);
|
|
144
|
+
if (job.location) console.log(`Location: ${job.location}`);
|
|
145
|
+
if (job.modality) console.log(`Modality: ${color('cyan', job.modality)}`);
|
|
146
|
+
if (job.url) console.log(`URL: ${color('dim', job.url)}`);
|
|
147
|
+
if (job.posted_at) console.log(`Posted: ${job.posted_at}`);
|
|
148
|
+
if (job.description_text) {
|
|
149
|
+
console.log();
|
|
150
|
+
console.log(color('bold', 'Description:'));
|
|
151
|
+
console.log(job.description_text.slice(0, 1000));
|
|
152
|
+
if (job.description_text.length > 1000) console.log(color('dim', '... (truncated)'));
|
|
153
|
+
}
|
|
154
|
+
} catch (e) {
|
|
155
|
+
outputError(e.error || e.message || 'Failed to fetch job');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function cmdCompanies(args) {
|
|
160
|
+
if (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
|
|
161
|
+
try {
|
|
162
|
+
const result = await apiRequest('GET', '/api/jobscout/companies');
|
|
163
|
+
outputCompanies(result.companies || result.data || []);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
outputError(e.error || e.message || 'Failed to fetch companies');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
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); }
|
|
173
|
+
try {
|
|
174
|
+
const result = await apiRequest('GET', `/api/jobscout/companies/search?q=${encodeURIComponent(query)}`);
|
|
175
|
+
outputCompanies(result.companies || result.results || []);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
outputError(e.error || e.message || 'Failed to search');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function cmdStats() {
|
|
182
|
+
if (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
|
|
183
|
+
try {
|
|
184
|
+
const result = await apiRequest('GET', '/api/jobscout/me');
|
|
185
|
+
outputStats(result);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
outputError(e.error || e.message || 'Failed to fetch stats');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
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); }
|
|
194
|
+
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`));
|
|
198
|
+
} catch (e) {
|
|
199
|
+
outputError(e.error || e.message || `Failed to ${action} job`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function cmdAsk(args) {
|
|
204
|
+
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); }
|
|
207
|
+
try {
|
|
208
|
+
const result = await apiRequest('GET', `/api/jobscout/ask?q=${encodeURIComponent(query)}`);
|
|
209
|
+
if (isJSON()) return console.log(JSON.stringify(result, null, 2));
|
|
210
|
+
console.log(color('bold', 'Parsed filters:'));
|
|
211
|
+
for (const [k, v] of Object.entries(result.parsedFilters || {})) {
|
|
212
|
+
console.log(` ${k}: ${color('cyan', String(v))}`);
|
|
213
|
+
}
|
|
214
|
+
if (result.jobsUrl && result.jobsUrl.startsWith('/api/')) {
|
|
215
|
+
console.log();
|
|
216
|
+
console.log(color('dim', `Fetching: ${result.jobsUrl}`));
|
|
217
|
+
// Auto-fetch jobs with parsed filters
|
|
218
|
+
const jobsResult = await apiRequest('GET', result.jobsUrl);
|
|
219
|
+
console.log();
|
|
220
|
+
outputJobs(jobsResult.jobs || jobsResult.data || []);
|
|
221
|
+
}
|
|
222
|
+
} 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');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function cmdApiKey(args) {
|
|
229
|
+
const subcommand = args._[0] || 'create';
|
|
230
|
+
if (!getToken()) { outputError('Not logged in. Run: trackly login'); process.exit(1); }
|
|
231
|
+
if (subcommand === 'create') {
|
|
232
|
+
const name = args.name || 'default';
|
|
233
|
+
try {
|
|
234
|
+
const result = await apiRequest('POST', '/api/auth/api-key', { name });
|
|
235
|
+
if (isJSON()) return console.log(JSON.stringify(result, null, 2));
|
|
236
|
+
console.log(color('green', 'API key created!'));
|
|
237
|
+
console.log();
|
|
238
|
+
console.log(`Key: ${color('bold', result.key)}`);
|
|
239
|
+
console.log(color('yellow', 'Save this key — it cannot be shown again.'));
|
|
240
|
+
} catch (e) {
|
|
241
|
+
outputError(e.error || e.message || 'Failed to create API key');
|
|
242
|
+
}
|
|
243
|
+
} else if (subcommand === 'list') {
|
|
244
|
+
try {
|
|
245
|
+
const result = await apiRequest('GET', '/api/auth/api-keys');
|
|
246
|
+
if (isJSON()) return console.log(JSON.stringify(result.keys, null, 2));
|
|
247
|
+
for (const key of (result.keys || [])) {
|
|
248
|
+
console.log(`${color('bold', key.key_prefix + '...')} — ${key.name} (created ${key.created_at})`);
|
|
249
|
+
}
|
|
250
|
+
} catch (e) {
|
|
251
|
+
outputError(e.error || e.message || 'Failed to list keys');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function cmdMcp() {
|
|
257
|
+
// Delegate to MCP server
|
|
258
|
+
require('../mcp/server');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function showHelp() {
|
|
262
|
+
console.log(`
|
|
263
|
+
${color('bold', 'trackly')} — Trackly job tracker CLI + MCP server
|
|
264
|
+
|
|
265
|
+
${color('bold', 'Usage:')}
|
|
266
|
+
trackly <command> [options]
|
|
267
|
+
|
|
268
|
+
${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)
|
|
284
|
+
|
|
285
|
+
${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
|
+
}
|
|
299
|
+
`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// -- Main --
|
|
303
|
+
|
|
304
|
+
async function main() {
|
|
305
|
+
const args = parseArgs(process.argv.slice(2));
|
|
306
|
+
|
|
307
|
+
if (!args.command || args.help || args.h) { showHelp(); process.exit(0); }
|
|
308
|
+
|
|
309
|
+
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;
|
|
324
|
+
default:
|
|
325
|
+
outputError(`Unknown command: ${args.command}`);
|
|
326
|
+
showHelp();
|
|
327
|
+
process.exit(1);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
main().catch(e => {
|
|
332
|
+
outputError(e.message || String(e));
|
|
333
|
+
process.exit(1);
|
|
334
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
## Trackly Job Tracker (MCP)
|
|
2
|
+
|
|
3
|
+
Trackly MCP server provides job search and tracking tools. Add to your Claude Code settings:
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
{
|
|
7
|
+
"mcpServers": {
|
|
8
|
+
"trackly": {
|
|
9
|
+
"command": "trackly",
|
|
10
|
+
"args": ["mcp"]
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Available Tools
|
|
17
|
+
|
|
18
|
+
- **trackly_search_jobs** — Search/filter jobs by function, location, modality, keywords
|
|
19
|
+
- **trackly_get_job** — Get full job details by ID
|
|
20
|
+
- **trackly_search_companies** — Semantic company search
|
|
21
|
+
- **trackly_list_companies** — List all tracked companies with job counts
|
|
22
|
+
- **trackly_get_stats** — Job tracker metrics dashboard
|
|
23
|
+
- **trackly_update_status** — Mark a job as applied, saved, or dismissed
|
|
24
|
+
- **trackly_ask** — Natural language job search (20/day limit)
|
|
25
|
+
|
|
26
|
+
### Example Prompts
|
|
27
|
+
|
|
28
|
+
- "Find me PM jobs at fintech companies"
|
|
29
|
+
- "What remote engineering roles are available?"
|
|
30
|
+
- "Show me jobs at Stripe"
|
|
31
|
+
- "Mark job 1234 as applied"
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
|
|
9
|
+
const CONFIG_DIR = path.join(os.homedir(), '.trackly');
|
|
10
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
11
|
+
const DEFAULT_BASE_URL = 'https://closeai.mba';
|
|
12
|
+
|
|
13
|
+
function loadConfig() {
|
|
14
|
+
try {
|
|
15
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
16
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
17
|
+
}
|
|
18
|
+
} catch (e) {}
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function saveConfig(config) {
|
|
23
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
24
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
25
|
+
}
|
|
26
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clearConfig() {
|
|
30
|
+
try { fs.unlinkSync(CONFIG_FILE); } catch (e) {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getBaseUrl() {
|
|
34
|
+
return loadConfig().baseUrl || DEFAULT_BASE_URL;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getToken() {
|
|
38
|
+
return loadConfig().token || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getRefreshToken() {
|
|
42
|
+
return loadConfig().refreshToken || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function refreshAccessToken() {
|
|
46
|
+
const rt = getRefreshToken();
|
|
47
|
+
if (!rt) return null;
|
|
48
|
+
const baseUrl = getBaseUrl();
|
|
49
|
+
if (!baseUrl.startsWith('https://') && !baseUrl.startsWith('http://localhost')) {
|
|
50
|
+
console.error('Warning: Refusing to send refresh token over insecure connection');
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const result = await apiRequest('POST', '/api/auth/refresh', { refreshToken: rt }, true);
|
|
55
|
+
if (result.success && result.token) {
|
|
56
|
+
const config = loadConfig();
|
|
57
|
+
config.token = result.token;
|
|
58
|
+
if (result.refreshToken) config.refreshToken = result.refreshToken;
|
|
59
|
+
saveConfig(config);
|
|
60
|
+
return result.token;
|
|
61
|
+
}
|
|
62
|
+
} catch (e) {}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry = false) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const baseUrl = getBaseUrl();
|
|
69
|
+
const url = new URL(endpoint, baseUrl);
|
|
70
|
+
const isHttps = url.protocol === 'https:';
|
|
71
|
+
const lib = isHttps ? https : http;
|
|
72
|
+
|
|
73
|
+
const headers = {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
'Accept': 'application/json',
|
|
76
|
+
'User-Agent': 'trackly-cli/0.1.0',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (!skipAuth) {
|
|
80
|
+
const token = getToken();
|
|
81
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const options = {
|
|
85
|
+
hostname: url.hostname,
|
|
86
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
87
|
+
path: url.pathname + url.search,
|
|
88
|
+
method,
|
|
89
|
+
headers,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const req = lib.request(options, (res) => {
|
|
93
|
+
let body = '';
|
|
94
|
+
res.on('data', chunk => body += chunk);
|
|
95
|
+
res.on('end', async () => {
|
|
96
|
+
let json;
|
|
97
|
+
try { json = JSON.parse(body); } catch { json = { raw: body }; }
|
|
98
|
+
|
|
99
|
+
if (res.statusCode === 401 && !skipAuth && !_isRetry) {
|
|
100
|
+
// Try token refresh (once only)
|
|
101
|
+
const newToken = await refreshAccessToken();
|
|
102
|
+
if (newToken) {
|
|
103
|
+
try {
|
|
104
|
+
const retried = await apiRequest(method, endpoint, data, false, true);
|
|
105
|
+
return resolve(retried);
|
|
106
|
+
} catch (e) { return reject(e); }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (res.statusCode >= 400) {
|
|
111
|
+
reject({ status: res.statusCode, ...json });
|
|
112
|
+
} else {
|
|
113
|
+
resolve(json);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
req.on('error', reject);
|
|
119
|
+
if (data) req.write(JSON.stringify(data));
|
|
120
|
+
req.end();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { loadConfig, saveConfig, clearConfig, apiRequest, getToken, CONFIG_DIR, CONFIG_FILE };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const c = {
|
|
4
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
5
|
+
green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m',
|
|
6
|
+
magenta: '\x1b[35m', cyan: '\x1b[36m', red: '\x1b[31m',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function color(name, text) {
|
|
10
|
+
return `${c[name] || ''}${text}${c.reset}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isJSON() {
|
|
14
|
+
return !process.stdout.isTTY || process.argv.includes('--json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function outputJobs(jobs) {
|
|
18
|
+
if (isJSON()) return console.log(JSON.stringify(jobs, null, 2));
|
|
19
|
+
if (!jobs || jobs.length === 0) {
|
|
20
|
+
console.log(color('dim', 'No jobs found.'));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
for (const job of jobs) {
|
|
24
|
+
const modality = job.modality ? color('cyan', `[${job.modality}]`) : '';
|
|
25
|
+
const location = job.location ? color('dim', job.location) : '';
|
|
26
|
+
console.log(`${color('bold', job.title)} ${modality}`);
|
|
27
|
+
console.log(` ${color('blue', job.company_name)} | ${location}`);
|
|
28
|
+
console.log(` ID: ${color('dim', job.id)} | Posted: ${color('dim', job.posted_at || job.first_seen_at || 'unknown')}`);
|
|
29
|
+
if (job.url) console.log(` ${color('dim', job.url)}`);
|
|
30
|
+
console.log();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function outputCompanies(companies) {
|
|
35
|
+
if (isJSON()) return console.log(JSON.stringify(companies, null, 2));
|
|
36
|
+
if (!companies || companies.length === 0) {
|
|
37
|
+
console.log(color('dim', 'No companies found.'));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const co of companies) {
|
|
41
|
+
const count = co.active_jobs_count || co.job_count || 0;
|
|
42
|
+
console.log(`${color('bold', co.name)} — ${color('cyan', count + ' jobs')}`);
|
|
43
|
+
if (co.domain) console.log(` ${color('dim', co.domain)}`);
|
|
44
|
+
console.log();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function outputStats(stats) {
|
|
49
|
+
if (isJSON()) return console.log(JSON.stringify(stats, null, 2));
|
|
50
|
+
console.log(color('bold', 'Trackly Stats'));
|
|
51
|
+
console.log();
|
|
52
|
+
if (stats.totalJobs !== undefined) console.log(`Total Jobs: ${color('cyan', stats.totalJobs)}`);
|
|
53
|
+
if (stats.totalCompanies !== undefined) console.log(`Companies: ${color('cyan', stats.totalCompanies)}`);
|
|
54
|
+
if (stats.appliedCount !== undefined) console.log(`Applied: ${color('green', stats.appliedCount)}`);
|
|
55
|
+
if (stats.savedCount !== undefined) console.log(`Saved: ${color('yellow', stats.savedCount)}`);
|
|
56
|
+
// Print any other keys
|
|
57
|
+
for (const [k, v] of Object.entries(stats)) {
|
|
58
|
+
if (!['totalJobs', 'totalCompanies', 'appliedCount', 'savedCount'].includes(k)) {
|
|
59
|
+
console.log(`${k}: ${color('dim', JSON.stringify(v))}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function outputError(msg) {
|
|
65
|
+
console.error(color('red', `Error: ${msg}`));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { color, isJSON, outputJobs, outputCompanies, outputStats, outputError };
|
package/mcp/server.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
4
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
5
|
+
const { z } = require('zod');
|
|
6
|
+
const { apiRequest, getToken } = require('../lib/client');
|
|
7
|
+
|
|
8
|
+
const server = new McpServer({
|
|
9
|
+
name: 'trackly',
|
|
10
|
+
version: '0.1.0',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Tool: Search/filter jobs
|
|
14
|
+
server.tool(
|
|
15
|
+
'trackly_search_jobs',
|
|
16
|
+
'Search and filter job postings. Returns matching jobs with title, company, location, modality.',
|
|
17
|
+
{
|
|
18
|
+
function: z.string().optional().describe('Job function filter: product_management, engineering, design, data_science, marketing, sales, finance, operations, legal, hr, other'),
|
|
19
|
+
location: z.string().optional().describe('Location filter (city or state)'),
|
|
20
|
+
modality: z.string().optional().describe('Work modality: remote, hybrid, onsite'),
|
|
21
|
+
status: z.string().optional().describe('Application status: new, saved, applied, dismissed'),
|
|
22
|
+
sort: z.string().optional().describe('Sort order: newest, oldest, company'),
|
|
23
|
+
limit: z.number().optional().describe('Max results (default 20, max 50)'),
|
|
24
|
+
offset: z.number().optional().describe('Pagination offset'),
|
|
25
|
+
keywords: z.string().optional().describe('Keyword search in title/description'),
|
|
26
|
+
},
|
|
27
|
+
async (params) => {
|
|
28
|
+
const qs = new URLSearchParams();
|
|
29
|
+
for (const [k, v] of Object.entries(params)) {
|
|
30
|
+
if (v !== undefined && v !== null) qs.set(k, String(v));
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const result = await apiRequest('GET', `/api/jobscout/jobs?${qs.toString()}`);
|
|
34
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Tool: Get job detail
|
|
42
|
+
server.tool(
|
|
43
|
+
'trackly_get_job',
|
|
44
|
+
'Get full details for a specific job posting including description.',
|
|
45
|
+
{
|
|
46
|
+
id: z.number().describe('Job posting ID'),
|
|
47
|
+
},
|
|
48
|
+
async ({ id }) => {
|
|
49
|
+
try {
|
|
50
|
+
const result = await apiRequest('GET', `/api/jobscout/jobs/${id}`);
|
|
51
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Tool: Search companies
|
|
59
|
+
server.tool(
|
|
60
|
+
'trackly_search_companies',
|
|
61
|
+
'Semantic search for companies by name, domain, or keywords.',
|
|
62
|
+
{
|
|
63
|
+
query: z.string().describe('Search query'),
|
|
64
|
+
limit: z.number().optional().describe('Max results (default 10)'),
|
|
65
|
+
},
|
|
66
|
+
async ({ query, limit }) => {
|
|
67
|
+
const qs = new URLSearchParams({ q: query });
|
|
68
|
+
if (limit) qs.set('limit', String(limit));
|
|
69
|
+
try {
|
|
70
|
+
const result = await apiRequest('GET', `/api/jobscout/companies/search?${qs.toString()}`);
|
|
71
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Tool: List companies
|
|
79
|
+
server.tool(
|
|
80
|
+
'trackly_list_companies',
|
|
81
|
+
'List all tracked companies with their active job counts.',
|
|
82
|
+
{
|
|
83
|
+
limit: z.number().optional().describe('Max results'),
|
|
84
|
+
offset: z.number().optional().describe('Pagination offset'),
|
|
85
|
+
},
|
|
86
|
+
async ({ limit, offset }) => {
|
|
87
|
+
const qs = new URLSearchParams();
|
|
88
|
+
if (limit) qs.set('limit', String(limit));
|
|
89
|
+
if (offset) qs.set('offset', String(offset));
|
|
90
|
+
try {
|
|
91
|
+
const result = await apiRequest('GET', `/api/jobscout/companies?${qs.toString()}`);
|
|
92
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Tool: Get stats
|
|
100
|
+
server.tool(
|
|
101
|
+
'trackly_get_stats',
|
|
102
|
+
'Get job tracker metrics: total jobs, companies, application status counts.',
|
|
103
|
+
{},
|
|
104
|
+
async () => {
|
|
105
|
+
try {
|
|
106
|
+
const result = await apiRequest('GET', '/api/jobscout/me');
|
|
107
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Tool: Update job status
|
|
115
|
+
server.tool(
|
|
116
|
+
'trackly_update_status',
|
|
117
|
+
'Update a job application status (apply, save, or dismiss).',
|
|
118
|
+
{
|
|
119
|
+
id: z.number().describe('Job posting ID'),
|
|
120
|
+
action: z.enum(['applied', 'saved', 'dismissed']).describe('Status action'),
|
|
121
|
+
},
|
|
122
|
+
async ({ id, action }) => {
|
|
123
|
+
try {
|
|
124
|
+
const result = await apiRequest('POST', '/api/jobscout-tracker/status', { jobId: id, action });
|
|
125
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
126
|
+
} catch (e) {
|
|
127
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Tool: Natural language search
|
|
133
|
+
server.tool(
|
|
134
|
+
'trackly_ask',
|
|
135
|
+
'Natural language job search. Describe what you are looking for and the AI parses it into structured filters. Limited to 20 queries per day.',
|
|
136
|
+
{
|
|
137
|
+
query: z.string().describe('Natural language search query, e.g. "PM jobs at fintech companies in SF"'),
|
|
138
|
+
},
|
|
139
|
+
async ({ query }) => {
|
|
140
|
+
try {
|
|
141
|
+
const askResult = await apiRequest('GET', `/api/jobscout/ask?q=${encodeURIComponent(query)}`);
|
|
142
|
+
// Auto-fetch jobs with parsed filters
|
|
143
|
+
if (askResult.jobsUrl && askResult.jobsUrl.startsWith('/api/')) {
|
|
144
|
+
const jobsResult = await apiRequest('GET', askResult.jobsUrl);
|
|
145
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...askResult, jobs: jobsResult.jobs || jobsResult.data || [] }, null, 2) }] };
|
|
146
|
+
}
|
|
147
|
+
return { content: [{ type: 'text', text: JSON.stringify(askResult, null, 2) }] };
|
|
148
|
+
} catch (e) {
|
|
149
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Start server
|
|
155
|
+
async function main() {
|
|
156
|
+
const transport = new StdioServerTransport();
|
|
157
|
+
await server.connect(transport);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
main().catch((e) => {
|
|
161
|
+
console.error('MCP server error:', e);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "trackly-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Trackly job tracker CLI + MCP server for AI agents",
|
|
5
|
+
"bin": {
|
|
6
|
+
"trackly": "./bin/trackly"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"lib/",
|
|
11
|
+
"mcp/",
|
|
12
|
+
"docs/",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"trackly",
|
|
17
|
+
"jobs",
|
|
18
|
+
"job-tracker",
|
|
19
|
+
"mcp",
|
|
20
|
+
"cli",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"ai-agent"
|
|
23
|
+
],
|
|
24
|
+
"author": "Kevin Astuhuaman <kevin.astuhuaman.flores@gmail.com>",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/kevinastuhuaman/trackly-cli"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
32
|
+
"zod": "^3.23.0"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
}
|
|
37
|
+
}
|