@scam-ai/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.
Files changed (3) hide show
  1. package/README.md +20 -0
  2. package/package.json +24 -0
  3. package/scamai.mjs +216 -0
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # @scam-ai/cli
2
+
3
+ Check a photo, video or audio file for AI generation or manipulation from the terminal.
4
+
5
+ ```
6
+ npx @scam-ai/cli check photo.jpg
7
+ ```
8
+
9
+ ```
10
+ export SCAMAI_API_KEY=sk_... # from app.scam.ai → API keys
11
+
12
+ scamai check <file> [--json] verdict, score, credits used
13
+ scamai balance credits remaining
14
+ scamai usage [--limit N] credit movements, newest first
15
+ scamai history past detection runs
16
+ scamai keys your API keys, masked
17
+ scamai whoami who this key belongs to
18
+ ```
19
+
20
+ Formats: JPG, PNG, WEBP · MP4, MOV, WEBM · MP3, WAV, M4A, FLAC, OGG, AAC. Anything else is refused. Built on [`@scam-ai/sdk`](https://www.npmjs.com/package/@scam-ai/sdk); API reference at https://scam.ai/docs.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@scam-ai/cli",
3
+ "version": "0.1.0",
4
+ "description": "scamai — command line client for the ScamAI detection platform, built on @scam-ai/sdk.",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "type": "module",
10
+ "bin": {
11
+ "scamai": "./scamai.mjs"
12
+ },
13
+ "files": [
14
+ "scamai.mjs",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "//dependencies": "@scam-ai/sdk is the REGISTRY version, not file:../sdk/typescript — a file: link survives npm pack and only fails on the customer's machine (see mcp/package.json).",
21
+ "dependencies": {
22
+ "@scam-ai/sdk": "^0.1.2"
23
+ }
24
+ }
package/scamai.mjs ADDED
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scamai — command line client.
4
+ *
5
+ * Same contract as everything else: your API key, the documented routes, the
6
+ * same credit gate. It adds no pricing path and no privileged endpoint of its
7
+ * own — anything this can do, curl can do; it just makes the common things
8
+ * one line.
9
+ *
10
+ * export SCAMAI_API_KEY=sk_...
11
+ * export SCAMAI_API_BASE=https://api.scam.ai # default
12
+ *
13
+ * scamai check ./photo.jpg # detect ANY media, prints the verdict
14
+ * scamai check ./clip.mp4 --json # same, as machine-readable JSON
15
+ * scamai balance # credits remaining
16
+ * scamai usage --limit 10 # recent credit movements
17
+ * scamai history # past detection runs
18
+ * scamai keys # list API keys (masked)
19
+ * scamai whoami # who this key belongs to
20
+ *
21
+ * Built on @scam-ai/sdk — the same client the MCP server uses, so the CLI can
22
+ * never disagree with it about routes, errors or retry rules.
23
+ *
24
+ * Credits, never dollars. It used to multiply by a hard-coded $0.04 — double
25
+ * the $0.02 the dashboard sells a credit at — so `scamai balance` and the
26
+ * Billing page disagreed about the same account by a factor of two. The API
27
+ * states no dollar price (`usage.pricing()` returns the per-check credit
28
+ * table, not a rate), so there is nothing honest to multiply by: what a
29
+ * credit costs is on the Billing page.
30
+ */
31
+ import { existsSync } from "node:fs";
32
+ import { basename } from "node:path";
33
+ import {
34
+ ScamAI,
35
+ AuthError,
36
+ ScopeError,
37
+ CreditsError,
38
+ RateLimitError,
39
+ UnprocessableError,
40
+ ConnectionError,
41
+ TimeoutError,
42
+ } from "@scam-ai/sdk";
43
+
44
+ const BASE = (process.env.SCAMAI_API_BASE || "https://api.scam.ai").replace(/\/$/, "");
45
+
46
+ const c = {
47
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
48
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
49
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
50
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
51
+ amber: (s) => `\x1b[33m${s}\x1b[0m`,
52
+ };
53
+
54
+ const credits = (n) => `${n} credit${n === 1 ? "" : "s"}`;
55
+
56
+ function die(msg, hint) {
57
+ console.error(`${c.red("✗")} ${msg}`);
58
+ if (hint) console.error(` ${c.dim(hint)}`);
59
+ process.exit(1);
60
+ }
61
+
62
+ function client() {
63
+ if (!process.env.SCAMAI_API_KEY) {
64
+ die("SCAMAI_API_KEY is not set.", "Create a key under Dashboard → API keys, then export it.");
65
+ }
66
+ return new ScamAI({ baseUrl: BASE });
67
+ }
68
+
69
+ /** The two errors people actually hit get their own sentence; the rest stay honest. */
70
+ function explain(e) {
71
+ if (e instanceof CreditsError) {
72
+ die(`Out of credits — balance ${e.balance ?? "?"}.`, "Top up under Dashboard → Billing, then retry.");
73
+ }
74
+ if (e instanceof AuthError) die("Unauthorized — the key was rejected.", "Check SCAMAI_API_KEY, or create a new key.");
75
+ if (e instanceof ScopeError) die(e.message, "This key's scope can't call that. Use a universal key.");
76
+ if (e instanceof RateLimitError) {
77
+ die("Rate limited.", e.retryAfterSeconds ? `Retry in ${e.retryAfterSeconds}s.` : "Back off and retry.");
78
+ }
79
+ if (e instanceof UnprocessableError) {
80
+ const codes = e.reasons.map((r) => r?.code ?? r).join(", ");
81
+ die(`The platform could not judge this file${codes ? ` (${codes})` : ""}.`, "That is an answer, not an outage — a different file may score.");
82
+ }
83
+ if (e instanceof TimeoutError) die("Timed out waiting.", "The run may still have completed AND been billed — check `scamai history` before retrying.");
84
+ if (e instanceof ConnectionError) die(`Could not reach ${BASE}`, "Is SCAMAI_API_BASE correct?");
85
+ die(e.message ?? String(e));
86
+ }
87
+
88
+ /* ------------------------------------------------------------ commands */
89
+
90
+ async function check(file, { json = false } = {}) {
91
+ if (!file) die("Usage: scamai check <file> [--json]");
92
+ if (!existsSync(file)) die(`No such file: ${file}`);
93
+
94
+ if (!json) process.stderr.write(c.dim(`checking ${basename(file)}…\n`));
95
+ const det = await client().detect(file);
96
+
97
+ if (json) {
98
+ console.log(JSON.stringify(det, null, 2));
99
+ return;
100
+ }
101
+
102
+ /* Three tiers, one fall-through. An unrecognised verdict paints as the
103
+ alerting one on purpose: the safe direction to be wrong is loud. */
104
+ const TIER = {
105
+ LIKELY_REAL: c.green("✓ LIKELY REAL"),
106
+ ALERT: c.amber("? ALERT"),
107
+ LIKELY_AI: c.red("! LIKELY AI"),
108
+ /* What a gateway answered before 2026-09-14, for a CLI pointed at one. */
109
+ LIKELY_AUTHENTIC: c.green("✓ LIKELY REAL"),
110
+ SUSPICIOUS: c.amber("? ALERT"),
111
+ LIKELY_AI_MANIPULATED: c.red("! LIKELY AI"),
112
+ };
113
+ const verdict = TIER[det.verdict] ?? c.red(`! ${String(det.verdict ?? "UNKNOWN")}`);
114
+
115
+ console.log(`\n ${verdict} ${c.dim(`(${det.media?.type ?? "media"})`)}`);
116
+ if (det.summary) console.log(` ${det.summary}`);
117
+ /* `score` is the envelope's one number (0–1, null when nothing scored). This
118
+ read `confidence`, which the 2026-09-15 envelope no longer carries, and
119
+ printed a dash for every run. */
120
+ console.log(` ${c.dim("score")} ${typeof det.score === "number" ? det.score.toFixed(2) : "—"}`);
121
+ console.log(` ${c.dim("model")} ${det.model ?? "unknown"}`);
122
+ /* What the ledger was debited for this run, as the API reported it. */
123
+ const charged = typeof det.credits_used === "number" ? credits(det.credits_used) : "not reported";
124
+ const why = det.zero_charge_reason ? c.dim(` (${det.zero_charge_reason})`) : "";
125
+ console.log(` ${c.dim("cost")} ${charged}${why}`);
126
+ console.log(`\n ${c.dim("AI isn't always right. Verify important results.")}\n`);
127
+ }
128
+
129
+ async function balance() {
130
+ const b = await client().account.balance();
131
+ console.log(`\n ${c.bold(String(b))} credits\n`);
132
+ if (b <= 0) console.log(` ${c.amber("Out of credits.")} Top up under Dashboard → Billing.\n`);
133
+ }
134
+
135
+ async function usage(limit = 20) {
136
+ const { entries = [], pagination } = await client().account.ledger({ limit });
137
+ if (!entries.length) return console.log(`\n ${c.dim("No credit movements yet.")}\n`);
138
+ console.log("");
139
+ for (const e of entries) {
140
+ const sign = e.delta >= 0 ? c.green(`+${e.delta}`) : c.red(String(e.delta));
141
+ const when = new Date(e.created_at).toISOString().slice(0, 16).replace("T", " ");
142
+ console.log(` ${c.dim(when)} ${sign.padStart(14)} ${e.reason} ${c.dim(e.ref ?? "")}`);
143
+ }
144
+ console.log(`\n ${c.dim(`${entries.length} of ${pagination?.total ?? entries.length} entries`)}\n`);
145
+ }
146
+
147
+ async function history() {
148
+ const { history: rows = [] } = await client().history.list({ limit: 20 });
149
+ if (!rows.length) return console.log(`\n ${c.dim("No detections yet. Try: scamai check ./photo.jpg")}\n`);
150
+ console.log("");
151
+ for (const d of rows.slice(0, 20)) {
152
+ const when = String(d.createdAt ?? d.created_at ?? "").slice(0, 16).replace("T", " ");
153
+ /* The charge the run recorded; a row that records none says so. */
154
+ const cost = typeof d.credits_used === "number" ? credits(d.credits_used) : "cost not recorded";
155
+ console.log(` ${c.dim(when)} ${d.serviceType ?? d.service_type ?? ""} ${c.dim(cost)} ${d.success === false ? c.red("failed") : ""}`);
156
+ }
157
+ console.log("");
158
+ }
159
+
160
+ async function keys() {
161
+ const list = await client().keys.list();
162
+ if (!list.length) return console.log(`\n ${c.dim("No active keys.")}\n`);
163
+ console.log("");
164
+ for (const k of list) {
165
+ const scope = k.isUniversal ? "universal" : (k.serviceType ?? "scoped");
166
+ console.log(` ${(k.name || "(unnamed)").padEnd(20)} ${c.dim(k.keyPreview ?? "")} ${c.dim(scope)}`);
167
+ }
168
+ console.log(`\n ${c.dim("Full keys are shown once, at creation — they are hashed at rest.")}\n`);
169
+ }
170
+
171
+ async function whoami() {
172
+ const u = await client().account.profile();
173
+ console.log(`\n ${c.bold(u.email ?? "unknown")} ${c.dim(u.tier ?? "")}\n`);
174
+ }
175
+
176
+ function help() {
177
+ console.log(`
178
+ ${c.bold("scamai")} — detect AI-generated and manipulated media
179
+
180
+ ${c.dim("USAGE")}
181
+ scamai check <file> [--json] Check a photo, video or audio file
182
+ scamai balance Credits remaining
183
+ scamai usage [--limit N] Credit movements, newest first
184
+ scamai history Past detection runs
185
+ scamai keys Your API keys (masked)
186
+ scamai whoami Who this key belongs to
187
+
188
+ ${c.dim("SETUP")}
189
+ export SCAMAI_API_KEY=sk_...
190
+ export SCAMAI_API_BASE=${BASE}
191
+ `);
192
+ }
193
+
194
+ /* --------------------------------------------------------------- main */
195
+ const [cmd, ...rest] = process.argv.slice(2);
196
+ const flag = (name, fallback) => {
197
+ const i = rest.indexOf(`--${name}`);
198
+ return i >= 0 ? rest[i + 1] : fallback;
199
+ };
200
+ const hasFlag = (name) => rest.includes(`--${name}`);
201
+
202
+ const commands = {
203
+ check: () => check(rest.find((a) => !a.startsWith("--")), { json: hasFlag("json") }),
204
+ balance,
205
+ usage: () => usage(Number(flag("limit", 20))),
206
+ history,
207
+ keys,
208
+ whoami,
209
+ help,
210
+ };
211
+
212
+ try {
213
+ await (commands[cmd] ?? help)();
214
+ } catch (e) {
215
+ explain(e);
216
+ }