trendsagi 0.19.0 → 0.20.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 +2 -2
- package/cli_spec.json +3 -2
- package/package.json +1 -1
- package/src/cli.js +110 -12
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
The TrendsAGI command line: what is trending on social platforms right now, with measured volume, velocity and growth, AI insights on why each topic is moving, evidence-backed recommendations, crisis events, your watchlist and read-only SQL, from a terminal or a shell script. No dependencies; Node.js 18 or later.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npx trendsagi login #
|
|
6
|
+
npx trendsagi login # approve a code in your browser (or --with-token in CI)
|
|
7
7
|
npx trendsagi trends list --category Technology --sort velocity --limit 10
|
|
8
8
|
```
|
|
9
9
|
|
|
@@ -56,7 +56,7 @@ trendsagi api /api/integrations/delivery/health -q lookback_hours=24 # any end
|
|
|
56
56
|
|
|
57
57
|
## How it behaves
|
|
58
58
|
|
|
59
|
-
- **Key:** `--api-key`, then `TRENDSAGI_API_KEY`, then the key `trendsagi login` saved to `~/.config/trendsagi/config.json` (`%APPDATA%\trendsagi` on Windows). The Python CLI (`pip install trendsagi`) reads the same file.
|
|
59
|
+
- **Key:** `--api-key`, then `TRENDSAGI_API_KEY`, then the key `trendsagi login` saved to `~/.config/trendsagi/config.json` (`%APPDATA%\trendsagi` on Windows). The Python CLI (`pip install trendsagi`) reads the same file, and so does the local MCP server: `trendsagi mcp` runs `trendsagi-mcp` with the same saved login.
|
|
60
60
|
- **Output:** lists print as tables in a terminal, and as JSON when piped or with `--json`: `trendsagi trends list | jq '.trends[].name'`.
|
|
61
61
|
- **Safety:** commands that delete something or change live ads ask first; pass `--yes` in scripts.
|
|
62
62
|
- **Exit codes:** 0 success, 1 API or request error, 2 invalid usage, 4 missing or rejected API key.
|
package/cli_spec.json
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
{
|
|
8
8
|
"name": "login",
|
|
9
9
|
"handler": "login",
|
|
10
|
-
"help": "
|
|
10
|
+
"help": "Sign in from this terminal: approve a short code in the browser, or save an API key.",
|
|
11
11
|
"options": [
|
|
12
|
-
{"flag": "with-token", "type": "flag", "help": "Read
|
|
12
|
+
{"flag": "with-token", "type": "flag", "help": "Read an API key from standard input instead of the browser sign-in."},
|
|
13
|
+
{"flag": "no-browser", "type": "flag", "help": "Print the sign-in URL instead of opening a browser."}
|
|
13
14
|
]
|
|
14
15
|
},
|
|
15
16
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trendsagi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "TrendsAGI CLI: real-time trending topics with measured volume, velocity and growth, AI insights, recommendations, crisis alerts and read-only SQL from your terminal. For paid media, growth and AI agent workflows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"trendsagi",
|
package/src/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { homedir } from "node:os";
|
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { createInterface } from "node:readline";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
14
15
|
|
|
15
16
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
17
|
export const SPEC = JSON.parse(readFileSync(join(here, "..", "cli_spec.json"), "utf8"));
|
|
@@ -18,6 +19,9 @@ const VERSION = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"
|
|
|
18
19
|
|
|
19
20
|
const DEFAULT_BASE_URL = "https://api.trendsagi.com";
|
|
20
21
|
const API_KEY_PREFIXES = ["ltc_live_", "ltc_test_"];
|
|
22
|
+
// RFC 8628 device login, shared with the Python CLI.
|
|
23
|
+
const DEVICE_CLIENT_ID = "trendsagi-cli";
|
|
24
|
+
const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
21
25
|
const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]);
|
|
22
26
|
const EXIT_ERROR = 1;
|
|
23
27
|
const EXIT_USAGE = 2;
|
|
@@ -554,16 +558,93 @@ async function runSpecCommand(ctx, cmd, args, options) {
|
|
|
554
558
|
return 0;
|
|
555
559
|
}
|
|
556
560
|
|
|
561
|
+
async function postForm(baseURL, path, data) {
|
|
562
|
+
let response;
|
|
563
|
+
try {
|
|
564
|
+
response = await fetch(baseURL + path, {
|
|
565
|
+
method: "POST",
|
|
566
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
567
|
+
body: new URLSearchParams(data).toString(),
|
|
568
|
+
});
|
|
569
|
+
} catch (error) {
|
|
570
|
+
throw new CLIError(`Could not reach ${baseURL}: ${error.message}`);
|
|
571
|
+
}
|
|
572
|
+
let body = {};
|
|
573
|
+
try {
|
|
574
|
+
body = await response.json();
|
|
575
|
+
} catch {
|
|
576
|
+
body = {};
|
|
577
|
+
}
|
|
578
|
+
return { status: response.status, body };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function openBrowser(url) {
|
|
582
|
+
if (!process.stderr.isTTY) return;
|
|
583
|
+
const [command, args] =
|
|
584
|
+
process.platform === "darwin" ? ["open", [url]]
|
|
585
|
+
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
|
586
|
+
: ["xdg-open", [url]];
|
|
587
|
+
try {
|
|
588
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
589
|
+
child.on("error", () => {});
|
|
590
|
+
child.unref();
|
|
591
|
+
} catch {
|
|
592
|
+
// The URL is printed either way.
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function deviceLogin(ctx, open) {
|
|
597
|
+
const started = await postForm(ctx.baseURL, "/oauth/device_authorization", {
|
|
598
|
+
client_id: DEVICE_CLIENT_ID,
|
|
599
|
+
scope: "trends:read",
|
|
600
|
+
resource: `${ctx.baseURL}/mcp`,
|
|
601
|
+
});
|
|
602
|
+
if (started.status !== 200) {
|
|
603
|
+
const detail = started.body.error_description || started.body.detail || `HTTP ${started.status}`;
|
|
604
|
+
throw new CLIError(`Could not start the login: ${detail}`);
|
|
605
|
+
}
|
|
606
|
+
const { verification_uri_complete: complete, user_code: userCode } = started.body;
|
|
607
|
+
if (!complete || !userCode) {
|
|
608
|
+
throw new CLIError("The server did not return a sign-in URL. Update the CLI, or use 'trendsagi login --with-token'.");
|
|
609
|
+
}
|
|
610
|
+
err(`Open ${complete}`);
|
|
611
|
+
err(`Approve code ${userCode} for TrendsAGI CLI.`);
|
|
612
|
+
if (open) openBrowser(complete);
|
|
613
|
+
|
|
614
|
+
let interval = Math.max(1, Number(started.body.interval) || 5);
|
|
615
|
+
const deadline = Date.now() + (Number(started.body.expires_in) || 600) * 1000;
|
|
616
|
+
// Tests set TRENDSAGI_DEVICE_POLL_MS so a pending poll does not take the
|
|
617
|
+
// real RFC interval; people never set it.
|
|
618
|
+
const override = process.env.TRENDSAGI_DEVICE_POLL_MS;
|
|
619
|
+
while (Date.now() < deadline) {
|
|
620
|
+
const delay = override ? Number(override) : interval * 1000;
|
|
621
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
622
|
+
const polled = await postForm(ctx.baseURL, "/oauth/token", {
|
|
623
|
+
grant_type: DEVICE_GRANT_TYPE,
|
|
624
|
+
device_code: started.body.device_code || "",
|
|
625
|
+
client_id: DEVICE_CLIENT_ID,
|
|
626
|
+
});
|
|
627
|
+
if (polled.status === 200 && polled.body.access_token) return String(polled.body.access_token);
|
|
628
|
+
const error = polled.body.error;
|
|
629
|
+
if (error === "authorization_pending") continue;
|
|
630
|
+
if (error === "slow_down") {
|
|
631
|
+
interval += 5;
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
if (error === "access_denied") throw new CLIError("The login was cancelled in the browser.", EXIT_AUTH);
|
|
635
|
+
if (error === "expired_token") throw new CLIError("The sign-in code expired. Run 'trendsagi login' again.", EXIT_AUTH);
|
|
636
|
+
const detail = polled.body.error_description || polled.body.detail || `HTTP ${polled.status}`;
|
|
637
|
+
throw new CLIError(`Login failed: ${detail}`);
|
|
638
|
+
}
|
|
639
|
+
throw new CLIError("Timed out waiting for approval in the browser. Run 'trendsagi login' again.", EXIT_AUTH);
|
|
640
|
+
}
|
|
641
|
+
|
|
557
642
|
const HANDLERS = {
|
|
558
643
|
async login(ctx, cmd, args, options) {
|
|
559
644
|
let key;
|
|
560
645
|
if (options["with-token"]) key = readStdin().split(/\r?\n/)[0].trim();
|
|
561
646
|
else if (ctx.flagApiKey) key = ctx.flagApiKey.trim();
|
|
562
|
-
else
|
|
563
|
-
if (!process.stdin.isTTY) throw new CLIError("No terminal to prompt on; pipe the key in with 'trendsagi login --with-token'.", EXIT_USAGE);
|
|
564
|
-
err(`Create an API key at ${SPEC.api_keys_url}`);
|
|
565
|
-
key = (await ask("Paste your TrendsAGI API key: ", { hidden: true })).trim();
|
|
566
|
-
}
|
|
647
|
+
else key = await deviceLogin(ctx, !options["no-browser"]);
|
|
567
648
|
if (!key) throw new CLIError("No API key given.", EXIT_USAGE);
|
|
568
649
|
if (!API_KEY_PREFIXES.some((prefix) => key.startsWith(prefix))) {
|
|
569
650
|
throw new CLIError("That is not a TrendsAGI API key; keys start with ltc_live_.", EXIT_USAGE);
|
|
@@ -749,19 +830,36 @@ function describeError(error) {
|
|
|
749
830
|
return [error?.stack || String(error), EXIT_ERROR];
|
|
750
831
|
}
|
|
751
832
|
|
|
833
|
+
// runMcp is the bridge this CLI promises: the MCP server itself ships with
|
|
834
|
+
// the Python package, but `trendsagi mcp` starts it with the login this
|
|
835
|
+
// command just saved, so the agent and the terminal share one credential.
|
|
836
|
+
function runMcp(args) {
|
|
837
|
+
const binary = process.env.TRENDSAGI_MCP_BIN || "trendsagi-mcp";
|
|
838
|
+
return new Promise((resolve) => {
|
|
839
|
+
const child = spawn(binary, args, { stdio: "inherit" });
|
|
840
|
+
child.on("error", (error) => {
|
|
841
|
+
if (error.code === "ENOENT") {
|
|
842
|
+
err("The local MCP server needs the Python package: pip install \"trendsagi[mcp]\" (Python 3.10+).");
|
|
843
|
+
err("It shares this CLI's saved login, so run 'trendsagi login' once.");
|
|
844
|
+
err("Or connect your AI client to the hosted server: https://api.trendsagi.com/mcp");
|
|
845
|
+
} else {
|
|
846
|
+
err(`Could not start ${binary}: ${error.message}`);
|
|
847
|
+
}
|
|
848
|
+
resolve(EXIT_USAGE);
|
|
849
|
+
});
|
|
850
|
+
child.on("exit", (code, signal) => resolve(signal ? 130 : code ?? 0));
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
752
854
|
export async function main(argv = process.argv.slice(2)) {
|
|
753
855
|
try {
|
|
754
856
|
if (argv.length === 1 && ["--version", "-V"].includes(argv[0])) {
|
|
755
857
|
out(`trendsagi ${VERSION}`);
|
|
756
858
|
return 0;
|
|
757
859
|
}
|
|
758
|
-
if (argv[0] === "mcp"
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
? "The local MCP server ships with the Python package (pip install \"trendsagi[mcp]\"). Or connect your AI client to the hosted server: https://api.trendsagi.com/mcp"
|
|
762
|
-
: "'scaffold' ships with the Python package: pip install trendsagi",
|
|
763
|
-
EXIT_USAGE,
|
|
764
|
-
);
|
|
860
|
+
if (argv[0] === "mcp") return runMcp(argv.slice(1));
|
|
861
|
+
if (argv[0] === "scaffold") {
|
|
862
|
+
throw new CLIError("'scaffold' ships with the Python package: pip install trendsagi", EXIT_USAGE);
|
|
765
863
|
}
|
|
766
864
|
const parsed = parse(argv);
|
|
767
865
|
if (parsed.version) {
|