mt-signals 1.0.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.
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { request, loadConfig, saveConfig, getBaseUrl, getApiKey } = require("../lib/client");
5
+ const { fmt, catColor, scoreColor, bar, sparkline } = require("../lib/format");
6
+
7
+ const args = process.argv.slice(2);
8
+ const flags = {};
9
+ const positional = [];
10
+ for (const a of args) {
11
+ if (a.startsWith("--")) {
12
+ const [k, v] = a.slice(2).split("=");
13
+ flags[k] = v === undefined ? true : v;
14
+ } else {
15
+ positional.push(a);
16
+ }
17
+ }
18
+ const cmd = positional[0];
19
+ const json = flags.json;
20
+
21
+ function out(data) {
22
+ if (json) return console.log(JSON.stringify(data, null, 2));
23
+ return data;
24
+ }
25
+
26
+ function die(msg) {
27
+ console.error(fmt.red(`Error: ${msg}`));
28
+ process.exit(1);
29
+ }
30
+
31
+ async function main() {
32
+ try {
33
+ switch (cmd) {
34
+ case "list":
35
+ case "signals":
36
+ case undefined: {
37
+ if (cmd === undefined && positional.length === 0 && !flags.category && !flags.sort && !flags.min && !flags.limit) {
38
+ // No args at all — show help
39
+ showHelp();
40
+ break;
41
+ }
42
+ const qs = [];
43
+ if (flags.category) qs.push(`category=${encodeURIComponent(flags.category)}`);
44
+ if (flags.min) qs.push(`min_score=${flags.min}`);
45
+ if (flags.limit) qs.push(`limit=${flags.limit}`);
46
+ if (flags.sort) qs.push(`sort=${flags.sort}`);
47
+ const query = qs.length > 0 ? `?${qs.join("&")}` : "";
48
+ const data = await request("GET", `/signals${query}`);
49
+ if (json) return out(data);
50
+
51
+ const sigs = data.signals || [];
52
+ if (sigs.length === 0) {
53
+ console.log(fmt.dim("\n No signals found.\n"));
54
+ break;
55
+ }
56
+
57
+ console.log(fmt.bold(`\n Research Signals (${data.total} total)\n`));
58
+ for (const s of sigs) {
59
+ const sc = scoreColor(s.score)(String((s.score || 0).toFixed(1)).padStart(4));
60
+ const cat = catColor(s.category).padEnd(22);
61
+ const topic = fmt.bold((s.topic || "").padEnd(35).slice(0, 35));
62
+ const src = fmt.dim((s.source || "").padEnd(8).slice(0, 8));
63
+ console.log(` ${sc} ${cat} ${topic} ${src} U:${bar(s.urgency, 10, 5)} B:${bar(s.buyer_intent, 10, 5)} D:${bar(s.durability, 10, 5)}`);
64
+ }
65
+ console.log(fmt.dim(`\n Last recalculated: ${data.last_recalculated || "never"}\n`));
66
+ break;
67
+ }
68
+
69
+ case "top": {
70
+ const limit = positional[1] || flags.limit || "10";
71
+ const data = await request("GET", `/signals?limit=${limit}`);
72
+ if (json) return out(data);
73
+
74
+ const sigs = data.signals || [];
75
+ console.log(fmt.bold(`\n Top ${sigs.length} Signals\n`));
76
+ for (let i = 0; i < sigs.length; i++) {
77
+ const s = sigs[i];
78
+ const rank = fmt.dim(`#${String(i + 1).padStart(2)}`);
79
+ const sc = scoreColor(s.score)(String((s.score || 0).toFixed(1)));
80
+ const cat = catColor(s.category);
81
+ console.log(` ${rank} ${sc} ${fmt.bold(s.topic || "")} ${cat}`);
82
+ if (s.summary) console.log(` ${fmt.dim(s.summary.slice(0, 90))}`);
83
+ }
84
+ console.log();
85
+ break;
86
+ }
87
+
88
+ case "trending":
89
+ case "hot": {
90
+ const data = await request("GET", "/signals/trending");
91
+ if (json) return out(data);
92
+
93
+ console.log(fmt.bold(`\n Trending Signals\n`));
94
+
95
+ const breakouts = data.breakouts || [];
96
+ if (breakouts.length > 0) {
97
+ console.log(fmt.yellow(` Breakouts (${breakouts.length})\n`));
98
+ for (const b of breakouts) {
99
+ console.log(` ${fmt.yellow("\u26a1")} ${fmt.bold(b.topic)} ${fmt.yellow(b.multiplier + "x")} ${fmt.dim(b.previous_avg + " \u2192 " + b.current)} ${catColor(b.category)}`);
100
+ }
101
+ console.log();
102
+ } else {
103
+ console.log(fmt.dim(" No breakout signals.\n"));
104
+ }
105
+
106
+ const hot = data.hot_signals || [];
107
+ if (hot.length > 0) {
108
+ console.log(fmt.green(` Hot Signals (score 6+)\n`));
109
+ for (const s of hot) {
110
+ const sc = scoreColor(s.score)(String((s.score || 0).toFixed(1)));
111
+ console.log(` ${sc} ${fmt.bold(s.topic || "")} ${catColor(s.category)}`);
112
+ if (s.summary) console.log(` ${fmt.dim(s.summary.slice(0, 90))}`);
113
+ }
114
+ } else {
115
+ console.log(fmt.dim(" No hot signals.\n"));
116
+ }
117
+ console.log();
118
+ break;
119
+ }
120
+
121
+ case "lookup":
122
+ case "topic": {
123
+ const topic = positional.slice(1).join(" ") || flags.topic;
124
+ if (!topic) die("Usage: mt-signals lookup <topic>");
125
+ const encoded = encodeURIComponent(topic.replace(/\s+/g, "-"));
126
+ const data = await request("GET", `/signals/${encoded}`);
127
+ if (json) return out(data);
128
+
129
+ const s = data.signal;
130
+ if (!s) { console.log(fmt.dim("\n Signal not found.\n")); break; }
131
+
132
+ console.log(fmt.bold(`\n ${s.topic}\n`));
133
+ console.log(` ${fmt.cyan("Score:")} ${scoreColor(s.score)(String((s.score || 0).toFixed(1)))}`);
134
+ console.log(` ${fmt.cyan("Category:")} ${catColor(s.category)}`);
135
+ console.log(` ${fmt.cyan("Source:")} ${s.source || "—"} (${s.source_count || 1} sources)`);
136
+ console.log(` ${fmt.cyan("Urgency:")} ${bar(s.urgency, 10, 15)} ${(s.urgency || 0).toFixed(1)}`);
137
+ console.log(` ${fmt.cyan("Buyer Intent:")} ${bar(s.buyer_intent, 10, 15)} ${(s.buyer_intent || 0).toFixed(1)}`);
138
+ console.log(` ${fmt.cyan("Durability:")} ${bar(s.durability, 10, 15)} ${(s.durability || 0).toFixed(1)}`);
139
+
140
+ if (s.summary) console.log(`\n ${fmt.dim(s.summary)}`);
141
+
142
+ if (s.actionable_for && s.actionable_for.length > 0) {
143
+ console.log(`\n ${fmt.cyan("Actionable for:")} ${s.actionable_for.join(", ")}`);
144
+ }
145
+
146
+ if (s.evidence && s.evidence.length > 0) {
147
+ console.log(fmt.bold("\n Evidence\n"));
148
+ for (const e of s.evidence.slice(0, 10)) {
149
+ console.log(` ${fmt.dim("\u2022")} ${e}`);
150
+ }
151
+ }
152
+
153
+ const hist = data.history || [];
154
+ if (hist.length > 2) {
155
+ const scores = hist.map(h => h.score);
156
+ console.log(`\n ${fmt.cyan("Trend:")} ${sparkline(scores)} ${fmt.dim("(" + hist.length + " data points)")}`);
157
+ }
158
+
159
+ console.log(fmt.dim(`\n Updated: ${s.updated || s.created || "—"}\n`));
160
+ break;
161
+ }
162
+
163
+ case "category":
164
+ case "cat": {
165
+ const cat = positional[1] || flags.category;
166
+ if (!cat) die("Usage: mt-signals category <name>\n Categories: ai, infrastructure, security, devtools, data, business, career, market");
167
+ const data = await request("GET", `/signals/category/${encodeURIComponent(cat)}`);
168
+ if (json) return out(data);
169
+
170
+ const sigs = data.signals || [];
171
+ console.log(fmt.bold(`\n ${catColor(data.category)} (${data.total} signals)\n`));
172
+ for (const s of sigs) {
173
+ const sc = scoreColor(s.score)(String((s.score || 0).toFixed(1)).padStart(4));
174
+ console.log(` ${sc} ${fmt.bold((s.topic || "").padEnd(35).slice(0, 35))} U:${(s.urgency || 0).toFixed(1)} B:${(s.buyer_intent || 0).toFixed(1)} D:${(s.durability || 0).toFixed(1)}`);
175
+ }
176
+ console.log();
177
+ break;
178
+ }
179
+
180
+ case "stats": {
181
+ const data = await request("GET", "/stats");
182
+ if (json) return out(data);
183
+
184
+ console.log(fmt.bold(`\n Signal Stats\n`));
185
+ console.log(` ${fmt.cyan("Total Signals:")} ${fmt.bold(String(data.total_signals))}`);
186
+ console.log(` ${fmt.cyan("History Entries:")} ${fmt.bold(String(data.history_entries))}`);
187
+
188
+ const cats = Object.entries(data.categories || {});
189
+ if (cats.length > 0) {
190
+ console.log(fmt.bold("\n Categories\n"));
191
+ for (const [name, info] of cats.sort((a, b) => b[1].count - a[1].count)) {
192
+ const avg = scoreColor(info.avg_score)(String(info.avg_score));
193
+ console.log(` ${catColor(name).padEnd(22)} ${String(info.count).padStart(3)} signals avg ${avg} ${fmt.dim("top: " + (info.top_topic || "—").slice(0, 30))}`);
194
+ }
195
+ }
196
+
197
+ const top = data.top_10 || [];
198
+ if (top.length > 0) {
199
+ console.log(fmt.bold("\n Top 10\n"));
200
+ for (let i = 0; i < top.length; i++) {
201
+ const s = top[i];
202
+ console.log(` ${fmt.dim("#" + (i + 1))} ${scoreColor(s.score)(String((s.score || 0).toFixed(1)))} ${fmt.bold(s.topic || "")} ${catColor(s.category)}`);
203
+ }
204
+ }
205
+ console.log();
206
+ break;
207
+ }
208
+
209
+ case "recalculate":
210
+ case "recalc": {
211
+ const data = await request("POST", "/trigger", { action: "recalculate" });
212
+ if (json) return out(data);
213
+ console.log(fmt.green(`\n Scores recalculated. ${data.total || 0} signals updated.\n`));
214
+ break;
215
+ }
216
+
217
+ case "config": {
218
+ const sub = positional[1];
219
+ if (sub === "set") {
220
+ const key = positional[2];
221
+ const val = positional[3];
222
+ if (!key || !val) die("Usage: mt-signals config set <key> <value>\n Keys: url, apiKey");
223
+ const cfg = loadConfig();
224
+ cfg[key] = val;
225
+ saveConfig(cfg);
226
+ console.log(fmt.green(`\n Set ${key} = ${val}\n`));
227
+ } else {
228
+ console.log(fmt.bold("\n Configuration\n"));
229
+ console.log(` ${fmt.cyan("URL:")} ${getBaseUrl()}`);
230
+ console.log(` ${fmt.cyan("API Key:")} ${getApiKey() ? fmt.dim(getApiKey().slice(0, 8) + "...") : fmt.dim("(not set)")}`);
231
+ console.log(` ${fmt.cyan("Config:")} ${require("path").join(require("os").homedir(), ".mt-signals", "config.json")}`);
232
+ console.log();
233
+ }
234
+ break;
235
+ }
236
+
237
+ case "help":
238
+ default: {
239
+ if (cmd && cmd !== "help") die(`Unknown command: ${cmd}\nRun 'mt-signals help' for usage.`);
240
+ showHelp();
241
+ break;
242
+ }
243
+ }
244
+ } catch (err) {
245
+ if (json) {
246
+ console.log(JSON.stringify({ error: err.message }));
247
+ process.exit(1);
248
+ }
249
+ die(err.message);
250
+ }
251
+ }
252
+
253
+ function showHelp() {
254
+ console.log(`
255
+ ${fmt.bold("mt-signals")} — Research Signal Intelligence
256
+
257
+ ${fmt.bold("USAGE")}
258
+ mt-signals <command> [options]
259
+
260
+ ${fmt.bold("COMMANDS")}
261
+ ${fmt.cyan("signals")} [--category=X] [--sort=X] List all scored signals
262
+ ${fmt.cyan("top")} [N] Top N signals (default: 10)
263
+ ${fmt.cyan("trending")} Breakout signals + hot (score 6+)
264
+ ${fmt.cyan("lookup")} <topic> Deep dive on a specific signal
265
+ ${fmt.cyan("category")} <name> Signals by category
266
+ ${fmt.cyan("stats")} Category breakdown + top 10
267
+ ${fmt.cyan("recalculate")} Force score recalculation
268
+ ${fmt.cyan("config")} [set <key> <value>] View/set configuration
269
+
270
+ ${fmt.bold("OPTIONS")}
271
+ --json Output raw JSON
272
+ --category=X Filter: ai, infrastructure, security, devtools, data, business, career, market
273
+ --sort=X Sort: score, urgency, buyer_intent, durability
274
+ --min=N Minimum score filter
275
+ --limit=N Max results
276
+
277
+ ${fmt.bold("ENVIRONMENT")}
278
+ MT_SIGNALS_URL API base URL (default: https://signals.metaltorque.dev)
279
+ MT_SIGNALS_API_KEY Access key for the API
280
+
281
+ ${fmt.bold("EXAMPLES")}
282
+ mt-signals signals # All signals ranked by score
283
+ mt-signals top 5 # Top 5 signals
284
+ mt-signals trending # Breakouts + hot signals
285
+ mt-signals signals --category=ai # AI signals only
286
+ mt-signals signals --sort=buyer_intent # Sort by buyer intent
287
+ mt-signals lookup "MCP server" # Deep dive
288
+ mt-signals stats --json # Pipe stats to another tool
289
+ `);
290
+ }
291
+
292
+ main();
package/lib/client.js ADDED
@@ -0,0 +1,69 @@
1
+ const https = require("https");
2
+ const http = require("http");
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), ".mt-signals");
8
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
9
+
10
+ function loadConfig() {
11
+ try { return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); } catch { return {}; }
12
+ }
13
+
14
+ function saveConfig(cfg) {
15
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
16
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
17
+ }
18
+
19
+ function getBaseUrl() {
20
+ return (process.env.MT_SIGNALS_URL || loadConfig().url || "https://signals.metaltorque.dev").replace(/\/$/, "");
21
+ }
22
+
23
+ function getApiKey() {
24
+ return process.env.MT_SIGNALS_API_KEY || loadConfig().apiKey || "";
25
+ }
26
+
27
+ function request(method, urlPath, body, timeout = 30000) {
28
+ return new Promise((resolve, reject) => {
29
+ const base = getBaseUrl();
30
+ const apiKey = getApiKey();
31
+ const sep = urlPath.includes("?") ? "&" : "?";
32
+ const fullUrl = apiKey ? `${base}${urlPath}${sep}key=${apiKey}` : `${base}${urlPath}`;
33
+ const mod = fullUrl.startsWith("https") ? https : http;
34
+ const parsed = new URL(fullUrl);
35
+
36
+ const headers = { "Content-Type": "application/json", "User-Agent": "mt-signals-cli/1.0.0" };
37
+
38
+ const opts = {
39
+ hostname: parsed.hostname,
40
+ port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
41
+ path: parsed.pathname + parsed.search,
42
+ method,
43
+ headers,
44
+ timeout,
45
+ };
46
+
47
+ const req = mod.request(opts, (res) => {
48
+ let data = "";
49
+ res.on("data", (c) => (data += c));
50
+ res.on("end", () => {
51
+ try {
52
+ const json = JSON.parse(data);
53
+ if (res.statusCode >= 400) return reject(new Error(json.error || `HTTP ${res.statusCode}`));
54
+ resolve(json);
55
+ } catch {
56
+ if (res.statusCode >= 400) return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 300)}`));
57
+ resolve(data);
58
+ }
59
+ });
60
+ });
61
+
62
+ req.on("error", reject);
63
+ req.on("timeout", () => { req.destroy(); reject(new Error("Request timed out")); });
64
+ if (body) req.write(JSON.stringify(body));
65
+ req.end();
66
+ });
67
+ }
68
+
69
+ module.exports = { request, loadConfig, saveConfig, getBaseUrl, getApiKey, CONFIG_DIR, CONFIG_FILE };
package/lib/format.js ADDED
@@ -0,0 +1,50 @@
1
+ const NO_COLOR = process.env.NO_COLOR || process.env.TERM === "dumb";
2
+ const c = (code, s) => NO_COLOR ? s : `\x1b[${code}m${s}\x1b[0m`;
3
+
4
+ const fmt = {
5
+ bold: (s) => c("1", s),
6
+ dim: (s) => c("2", s),
7
+ green: (s) => c("32", s),
8
+ red: (s) => c("31", s),
9
+ yellow: (s) => c("33", s),
10
+ cyan: (s) => c("36", s),
11
+ magenta: (s) => c("35", s),
12
+ blue: (s) => c("34", s),
13
+ white: (s) => c("37", s),
14
+ };
15
+
16
+ const CAT_COLORS = {
17
+ ai: fmt.magenta,
18
+ infrastructure: fmt.green,
19
+ security: fmt.red,
20
+ devtools: fmt.yellow,
21
+ data: fmt.blue,
22
+ business: fmt.magenta,
23
+ career: fmt.cyan,
24
+ market: fmt.yellow,
25
+ };
26
+
27
+ function catColor(cat) {
28
+ return (CAT_COLORS[cat] || fmt.dim)(cat || "unknown");
29
+ }
30
+
31
+ function scoreColor(score) {
32
+ if (score >= 7) return fmt.green;
33
+ if (score >= 4) return fmt.yellow;
34
+ return fmt.dim;
35
+ }
36
+
37
+ function bar(value, max, width = 10) {
38
+ const filled = Math.round((value / (max || 10)) * width);
39
+ const empty = width - filled;
40
+ return fmt.cyan("\u2588".repeat(filled)) + fmt.dim("\u2591".repeat(empty));
41
+ }
42
+
43
+ function sparkline(values) {
44
+ const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
45
+ if (!values.length) return "";
46
+ const max = Math.max(...values, 1);
47
+ return values.map((v) => fmt.cyan(chars[Math.min(Math.floor((v / max) * 7), 7)])).join("");
48
+ }
49
+
50
+ module.exports = { fmt, catColor, scoreColor, bar, sparkline };
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "mt-signals",
3
+ "version": "1.0.0",
4
+ "description": "CLI for MetalTorque Research Signals — scored trend intelligence from HN, Reddit, ArXiv, and GitHub.",
5
+ "bin": {
6
+ "mt-signals": "bin/mt-signals.js"
7
+ },
8
+ "keywords": ["research", "signals", "trends", "market-intelligence", "ai", "swarm", "cli"],
9
+ "license": "MIT",
10
+ "author": "MetalTorque",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/joepangallo/research-signal-api"
14
+ },
15
+ "engines": {
16
+ "node": ">=16"
17
+ },
18
+ "files": [
19
+ "bin/",
20
+ "lib/",
21
+ "README.md"
22
+ ]
23
+ }