@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.
- package/README.md +20 -0
- package/package.json +24 -0
- 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
|
+
}
|