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 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
+ }