@sprid/cli 0.1.5 → 0.1.7

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/CHANGELOG.md CHANGED
@@ -3,6 +3,30 @@
3
3
  sprid follows semver: a breaking change to a command's arguments, its output
4
4
  shape or its exit codes is a major release.
5
5
 
6
+ ## 0.1.7 - 2026-09-24
7
+
8
+ - `sprid connect meta-ads --account <slug>` connects the Meta ad account Sprid
9
+ runs campaigns in. `--page` and `--ad-account` pick among several, `--dataset`
10
+ binds the Events Manager dataset conversion campaigns report to. `sprid docs
11
+ meta-ads` is the setup guide.
12
+ - Usage telemetry. When signed in, each command reports its name, a documented
13
+ subcommand, exit code, error kind, duration and versions to your Sprid
14
+ account, so failures and slow commands show up without anyone filing a bug.
15
+ Never arguments, paths or file contents, never a third party. The first run
16
+ only prints a notice. `sprid telemetry off`, `DO_NOT_TRACK=1` or
17
+ `SPRID_TELEMETRY=0` turns it off; `sprid telemetry` shows which setting
18
+ decided.
19
+
20
+ ## 0.1.6 - 2026-09-24
21
+
22
+ - `sprid connect <service> --key-from-clipboard` reads a copied key from the
23
+ clipboard, saves it to Sprid and then empties the clipboard, so the key never
24
+ reaches the screen, shell history or a file. It covers every service whose
25
+ key is a copied string (RevenueCat, Stripe, PostHog, Polar, Paddle, Lemon
26
+ Squeezy, Plausible, Umami, Cloudflare); the ones whose credential is a
27
+ downloaded file keep `--key <file>`. macOS, Windows, and Linux through
28
+ `wl-paste`, `xclip` or `xsel`.
29
+
6
30
  ## 0.1.5 - 2026-09-24
7
31
 
8
32
  - `sprid connect ga4|plausible|umami` saves a website-analytics key beside the
package/README.md CHANGED
@@ -216,8 +216,19 @@ body of `PATCH /api/app-profiles/:id`. They are stored encrypted and no route ev
216
216
  returns them. `sprid connect <platform>` only opens the platform’s OAuth page and waits for the
217
217
  result. Publishing requires the relevant approval or publishing command.
218
218
 
219
- No background telemetry. Credentials stay out of normal output. Build tools can
220
- print their own logs; do not include secrets in custom build commands.
219
+ Usage telemetry: when you are signed in, each command ends with one request to
220
+ `POST /api/cli/events` on the API you are signed in to, carrying the command name,
221
+ a documented subcommand (`post push`, never a slug or a file name), the exit code,
222
+ an error kind (an HTTP status, or usage/network/other, never the message), the
223
+ duration, the CLI and Node versions, the OS and whether `CI` is set. Never
224
+ arguments, paths, output or file contents, and nothing goes to any third party.
225
+ The first run that could send only prints a notice. It waits at most one second
226
+ and a failure is ignored. Turn it off with `sprid telemetry off` (saved in
227
+ `~/.sprid/telemetry.json`), or for one shell with `DO_NOT_TRACK=1` or
228
+ `SPRID_TELEMETRY=0`; `help`, `version`, `completion` and `mcp` never send.
229
+
230
+ Credentials stay out of normal output. Build tools can print their own logs; do
231
+ not include secrets in custom build commands.
221
232
 
222
233
  ## Updates
223
234
 
package/bin.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from "./src/cli.mjs";
3
3
 
4
- run(process.argv.slice(2), { notifyUpdates: true }).then(
4
+ run(process.argv.slice(2), { notifyUpdates: true, telemetry: true }).then(
5
5
  // Let stdout drain. An explicit exit truncates large review packets when
6
6
  // another process is reading the CLI through a pipe.
7
7
  (code) => { process.exitCode = code ?? 0; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprid/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "The Sprid command line: connect your app, review results, prepare posts and store screenshots, and release mobile apps.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@sprid/shots": "0.1.1",
42
- "@sprid/release": "0.1.1"
42
+ "@sprid/release": "0.1.2"
43
43
  },
44
44
  "publishConfig": {
45
45
  "access": "public"
package/src/args.mjs CHANGED
@@ -15,7 +15,10 @@ export const VALUE_FLAGS = new Set([
15
15
  "platform",
16
16
  "entry",
17
17
  "account",
18
+ "accounts",
18
19
  "page",
20
+ "ad-account",
21
+ "dataset",
19
22
  "app",
20
23
  "key",
21
24
  "key-id",
@@ -62,9 +65,27 @@ export const VALUE_FLAGS = new Set([
62
65
  "alt-text",
63
66
  "ai-disclosures",
64
67
  "bookmark",
68
+ // tool, events, inbox, ads, schedule
69
+ "args",
70
+ "kind",
71
+ "label",
72
+ "areas",
73
+ "why",
74
+ "at",
75
+ "intent",
76
+ "count",
77
+ "daily-cents",
78
+ "objective",
79
+ "platforms",
80
+ "options",
81
+ "ids",
82
+ "cadence",
83
+ "times",
84
+ "start",
85
+ "time",
65
86
  ]);
66
87
 
67
- export const BOOL_FLAGS = new Set(["json", "offline", "check", "no-browser", "from-local", "help", "version", "unanswered", "yes", "clear", "refresh", "cached-only"]);
88
+ export const BOOL_FLAGS = new Set(["json", "offline", "check", "no-browser", "from-local", "help", "version", "unanswered", "yes", "clear", "refresh", "cached-only", "next", "queue-end", "shift-later", "dry-run", "skip-busy"]);
68
89
 
69
90
  /**
70
91
  * `sprid connect asc --app myapp --key ./k.p8 --json`
package/src/cli.mjs CHANGED
@@ -39,6 +39,11 @@ import { getHelp, renderHelp, suggest } from "./docs/help.mjs";
39
39
  import { completion } from "./commands/completion.mjs";
40
40
 
41
41
  import { doctor, update, notifyUpdate } from './commands/update.mjs';
42
+ import { telemetry, reportCommand } from './telemetry.mjs';
43
+ import { tool } from "./commands/tool.mjs";
44
+ import { events } from "./commands/events.mjs";
45
+ import { inbox } from "./commands/inbox.mjs";
46
+ import { ads } from "./commands/ads.mjs";
42
47
 
43
48
  export const VERSION = readVersion();
44
49
 
@@ -54,7 +59,7 @@ function readVersion() {
54
59
  export const COMMANDS = {
55
60
  plan, research,
56
61
  media, capabilities, completion,
57
- screenshots, release, doctor, update,
62
+ screenshots, release, doctor, update, telemetry,
58
63
  apps, insights, account,
59
64
  docs,
60
65
  login: auth.login,
@@ -75,6 +80,10 @@ export const COMMANDS = {
75
80
  reviews: reviews.reviews,
76
81
  "marketing-review": marketingReview,
77
82
  post: post.post,
83
+ tool,
84
+ events,
85
+ inbox,
86
+ ads,
78
87
  pinterest,
79
88
  help: help,
80
89
  };
@@ -240,18 +249,31 @@ export async function run(argv, io = {}) {
240
249
  return 2;
241
250
  }
242
251
 
252
+ const startedAt = Date.now();
253
+ let code, failure = null;
243
254
  try {
244
255
  if (io.notifyUpdates && !flags.offline && ["status", "next"].includes(command)) await notifyUpdate(ctx).catch(() => {});
245
- const code = await fn(ctx);
246
- return code ?? 0;
256
+ code = (await fn(ctx)) ?? 0;
247
257
  } catch (e) {
248
- const code = e && e.exitCode ? e.exitCode : 1;
258
+ failure = e;
259
+ code = e && e.exitCode ? e.exitCode : 1;
249
260
  const message = e && e.message ? e.message : String(e);
250
261
  if (json) {
251
262
  write(JSON.stringify({ ok: false, error: message, status: e?.status ?? null }) + "\n");
252
263
  } else {
253
264
  writeErr(` ${C.bad("✗")} ${message}\n`);
254
265
  }
255
- return code;
256
266
  }
267
+ // Only the installed binary reports (bin.mjs passes this); tests and
268
+ // embedders do not. See telemetry.mjs for exactly what is sent.
269
+ if (io.telemetry) {
270
+ await reportCommand(ctx, {
271
+ command,
272
+ words: passthrough ? parsed.commandArgv : positionals,
273
+ exitCode: code,
274
+ durationMs: Date.now() - startedAt,
275
+ error: failure,
276
+ });
277
+ }
278
+ return code;
257
279
  }
@@ -0,0 +1,75 @@
1
+ // The clipboard as a key source. A key copied out of a provider's dashboard
2
+ // goes straight from the clipboard into the request: it never reaches the
3
+ // screen, a chat, shell history or a file on disk. Nothing here prints what it
4
+ // read.
5
+
6
+ import { spawnSync } from "node:child_process";
7
+ import { UsageError } from "./args.mjs";
8
+
9
+ // First reader that exists wins. Linux has no single clipboard tool, so try
10
+ // Wayland, then the two X11 ones.
11
+ const READERS = {
12
+ darwin: [["pbpaste", []]],
13
+ win32: [["powershell", ["-NoProfile", "-Command", "Get-Clipboard -Raw"]]],
14
+ linux: [
15
+ ["wl-paste", ["--no-newline"]],
16
+ ["xclip", ["-selection", "clipboard", "-o"]],
17
+ ["xsel", ["--clipboard", "--output"]],
18
+ ],
19
+ };
20
+ const CLEARERS = {
21
+ darwin: [["pbcopy", []]],
22
+ win32: [["powershell", ["-NoProfile", "-Command", "Set-Clipboard -Value $null"]]],
23
+ linux: [
24
+ ["wl-copy", ["--clear"]],
25
+ ["xclip", ["-selection", "clipboard", "-i"]],
26
+ ["xsel", ["--clipboard", "--clear"]],
27
+ ],
28
+ };
29
+
30
+ // A clearer's stdout is ignored, not piped: xclip forks a child that keeps
31
+ // owning the selection, and a pipe it inherits would hold spawnSync open.
32
+ function run(candidates, input) {
33
+ const clearing = input !== undefined;
34
+ for (const [cmd, args] of candidates ?? []) {
35
+ const r = spawnSync(cmd, args, { input: input ?? "", encoding: "utf8", stdio: ["pipe", clearing ? "ignore" : "pipe", "ignore"] });
36
+ if (r.error?.code === "ENOENT") continue;
37
+ if (r.status === 0) return { ok: true, out: r.stdout ?? "" };
38
+ }
39
+ return { ok: false, out: "" };
40
+ }
41
+
42
+ /**
43
+ * Check what the clipboard holds looks like a key, not what is usually there
44
+ * by mistake: nothing, or the command itself (copied out of a guide or a chat
45
+ * a moment before). Keys and tokens from every provider we read are one
46
+ * unbroken string, so any whitespace means it is not one.
47
+ */
48
+ export function checkClipboardKey(raw, what = "the key") {
49
+ const text = String(raw ?? "").trim();
50
+ if (!text) throw new UsageError(`The clipboard is empty. Copy ${what}, then run this again.`);
51
+ if (/\s/.test(text)) {
52
+ throw new UsageError(
53
+ `The clipboard holds text with spaces, not a key (often this command itself). Copy ${what}, then run this again.`,
54
+ );
55
+ }
56
+ if (text.length < 8) throw new UsageError(`The clipboard holds something too short to be a key. Copy ${what}, then run this again.`);
57
+ return text;
58
+ }
59
+
60
+ export function readClipboardKey(what, platform = process.platform, exec = run) {
61
+ const r = exec(READERS[platform]);
62
+ if (!r.ok) {
63
+ throw new UsageError(
64
+ platform === "linux"
65
+ ? "Could not read the clipboard. Install wl-clipboard, xclip or xsel, or save the key to a file and pass --key <file>."
66
+ : "Could not read the clipboard. Save the key to a file and pass --key <file> instead.",
67
+ );
68
+ }
69
+ return checkClipboardKey(r.out, what);
70
+ }
71
+
72
+ /** Best effort: a key left on the clipboard is the next paste's accident. */
73
+ export function clearClipboard(platform = process.platform, exec = run) {
74
+ return exec(CLEARERS[platform], "").ok;
75
+ }
@@ -0,0 +1,110 @@
1
+ // sprid ads: paid campaigns on Meta through the same tools the ads skill
2
+ // calls. Everything is created paused. The two verbs that change what is spent
3
+ // (launch, budget) show what they commit and ask, and need a token holding
4
+ // ads:spend; the server refuses them otherwise.
5
+
6
+ import { UsageError } from "../args.mjs";
7
+ import { C, OK } from "../format.mjs";
8
+ import { callTool, needInt, readJson, show } from "../tools.mjs";
9
+ import { confirm } from "./reviews.mjs";
10
+
11
+ const USAGE = [
12
+ "sprid ads [overview] --account <slug>",
13
+ "sprid ads status|council|metrics <adSetId> --account <slug> [--days N]",
14
+ "sprid ads create campaign|set|creative|ad|batch --file <args.json> --account <slug>",
15
+ "sprid ads launch <adSetId> --account <slug> [--yes]",
16
+ "sprid ads budget <adSetId> --daily-cents N --account <slug> [--yes]",
17
+ "sprid ads pause <adSetId> --account <slug> [--yes]",
18
+ "sprid ads close <adSetId> --account <slug>",
19
+ ].join("\n ");
20
+
21
+ /** What `create <thing>` calls. The file carries the tool's arguments. */
22
+ const CREATE = {
23
+ campaign: "create_ad_campaign",
24
+ set: "create_ad_set",
25
+ creative: "create_ad_creative",
26
+ ad: "create_ad",
27
+ batch: "create_ad_batch",
28
+ };
29
+ const READS = { status: "ad_status", council: "ad_council", metrics: "ad_metrics" };
30
+
31
+ export async function ads(ctx) {
32
+ const [sub, arg] = ctx.positionals;
33
+ const account = ctx.flags.account;
34
+ if (typeof account !== "string") throw new UsageError(USAGE);
35
+ if (!sub || sub === "overview") {
36
+ show(ctx, await callTool(ctx, "ads_overview", { account }));
37
+ return 0;
38
+ }
39
+ if (READS[sub]) {
40
+ const adSetId = needInt(arg, `sprid ads ${sub} <adSetId> --account <slug>`);
41
+ const days = ctx.flags.days && sub !== "status" ? { days: Number(ctx.flags.days) } : {};
42
+ show(ctx, await callTool(ctx, READS[sub], { account, adSetId, ...days }));
43
+ return 0;
44
+ }
45
+ if (sub === "create") {
46
+ const tool = CREATE[arg];
47
+ if (!tool || typeof ctx.flags.file !== "string") throw new UsageError(`sprid ads create ${Object.keys(CREATE).join("|")} --file <args.json> --account <slug>\n Arguments: sprid tool describe ${tool ?? "create_ad_campaign"}`);
48
+ show(ctx, await callTool(ctx, tool, { ...(await readJson(ctx, ctx.flags.file)), account }));
49
+ return 0;
50
+ }
51
+ if (sub === "launch") return launch(ctx, account, needInt(arg, "sprid ads launch <adSetId> --account <slug> [--yes]"));
52
+ if (sub === "budget") return budget(ctx, account, needInt(arg, "sprid ads budget <adSetId> --daily-cents N --account <slug> [--yes]"));
53
+ if (sub === "pause") return pause(ctx, account, needInt(arg, "sprid ads pause <adSetId> --account <slug> [--yes]"));
54
+ if (sub === "close") {
55
+ show(ctx, await callTool(ctx, "close_ad_set", { account, adSetId: needInt(arg, "sprid ads close <adSetId> --account <slug>") }));
56
+ return 0;
57
+ }
58
+ throw new UsageError(USAGE);
59
+ }
60
+
61
+ /**
62
+ * Ask before a call that spends or resets learning. --yes skips the prompt.
63
+ * Without a terminal (or with --json) there is nobody to ask, so it stops
64
+ * with exit 2; a person who answers no gets exit 1.
65
+ */
66
+ async function approve(ctx, summary, question) {
67
+ if (ctx.flags.yes) return 0;
68
+ if (ctx.json || !ctx.stdin?.isTTY) {
69
+ const msg = "Re-run with --yes to go ahead without the prompt.";
70
+ if (ctx.json) ctx.out({ ok: false, error: msg, summary });
71
+ else {
72
+ ctx.print(` ${summary}`);
73
+ ctx.warn(` ✗ ${msg}`);
74
+ }
75
+ return 2;
76
+ }
77
+ ctx.print(` ${summary}\n`);
78
+ if (await confirm(ctx, ` ${question} [y/N] `)) return 0;
79
+ ctx.print(" Nothing changed.");
80
+ return 1;
81
+ }
82
+
83
+ async function launch(ctx, account, adSetId) {
84
+ const preview = await callTool(ctx, "launch_ads", { account, adSetId, confirmed: false });
85
+ const stop = await approve(ctx, preview?.message ?? `Launch ad set ${adSetId}.`, "Launch and start spending?");
86
+ if (stop) return stop;
87
+ const res = await callTool(ctx, "launch_ads", { account, adSetId, confirmed: true });
88
+ if (ctx.json) ctx.out(res);
89
+ else ctx.print(` ${C.ok(OK)} Ad set ${adSetId} is live. Leave it alone for 3 days: every edit resets Meta's learning phase.`);
90
+ return 0;
91
+ }
92
+
93
+ async function budget(ctx, account, adSetId) {
94
+ const cents = Number(ctx.flags["daily-cents"]);
95
+ if (!Number.isInteger(cents) || cents <= 0) throw new UsageError("Pass the new daily budget in cents: --daily-cents 1500 is 15.00 a day.");
96
+ const summary = `Set ad set ${adSetId}'s daily budget to ${(cents / 100).toFixed(2)}. It is checked against the account's daily and per-campaign caps.`;
97
+ const stop = await approve(ctx, summary, "Change the budget?");
98
+ if (stop) return stop;
99
+ show(ctx, await callTool(ctx, "update_ad_budget", { account, adSetId, dailyBudgetCents: cents }));
100
+ return 0;
101
+ }
102
+
103
+ async function pause(ctx, account, adSetId) {
104
+ const stop = await approve(ctx, `Pause ad set ${adSetId}. Pausing resets Meta's learning phase when it resumes.`, "Pause it?");
105
+ if (stop) return stop;
106
+ const res = await callTool(ctx, "pause_ad_set", { account, adSetId });
107
+ if (ctx.json) ctx.out(res);
108
+ else ctx.print(` ${C.ok(OK)} Ad set ${adSetId} paused.`);
109
+ return 0;
110
+ }
@@ -15,12 +15,32 @@ import { UsageError } from "../args.mjs";
15
15
  import { secretsPath } from "../creds.mjs";
16
16
  import { ApiError } from "../http.mjs";
17
17
  import { pendingGrant } from "../pending.mjs";
18
+ import { clearClipboard, readClipboardKey } from "../clipboard.mjs";
18
19
  import { C, BAD, EXPIRING, NONE, OK, table, wrap } from "../format.mjs";
19
20
  import { connectionRows } from "./status.mjs";
20
21
  import { confirm } from "./reviews.mjs";
21
22
  import { expandHome, listProfiles, patchProfile, readKeyFile, readLocalApp, resolveProfile } from "../profiles.mjs";
22
23
 
23
24
  export const CHANNEL_PLATFORMS = ["instagram", "tiktok", "youtube", "linkedin", "facebook", "pinterest", "x"];
25
+ // Connects like a channel, through the platform's sign-in, but never publishes:
26
+ // it is the ad account Sprid runs campaigns in. The CLI spells it with a hyphen
27
+ // like every other target; the server's platform id has an underscore.
28
+ const AD_CHANNELS = {
29
+ "meta-ads": "meta_ads",
30
+ meta_ads: "meta_ads",
31
+ "google-ads": "google_ads",
32
+ google_ads: "google_ads",
33
+ "tiktok-ads": "tiktok_ads",
34
+ tiktok_ads: "tiktok_ads",
35
+ };
36
+ // Connects like a channel but neither publishes nor pays: the TikTok account
37
+ // holder's grant that lets the Inbox read and answer comments on its posts.
38
+ const INBOX_CHANNELS = {
39
+ "tiktok-comments": "tiktok_accounts",
40
+ "tiktok-accounts": "tiktok_accounts",
41
+ tiktok_accounts: "tiktok_accounts",
42
+ };
43
+ const channelPlatform = (what) => (CHANNEL_PLATFORMS.includes(what) ? what : AD_CHANNELS[what] ?? INBOX_CHANNELS[what]);
24
44
  export const SENSOR_SERVICES = [
25
45
  "asc",
26
46
  "play",
@@ -84,11 +104,11 @@ export async function connect(ctx) {
84
104
  const [what] = ctx.positionals;
85
105
  if (ctx.flags["from-local"]) return fromLocal(ctx);
86
106
  if (!what) return wizard(ctx);
87
- if (CHANNEL_PLATFORMS.includes(what)) return connectChannel(ctx, what);
107
+ if (channelPlatform(what)) return connectChannel(ctx, channelPlatform(what));
88
108
  if (SENSOR_SERVICES.includes(what)) return connectSensor(ctx, what);
89
109
  if (PROVIDERS.includes(what)) return connectProvider(ctx, what);
90
110
  throw new UsageError(
91
- `Unknown target "${what}". Channels: ${CHANNEL_PLATFORMS.join(", ")}. Keys: ${SENSOR_SERVICES.join(", ")}. Model keys: ${PROVIDERS.join(", ")}.`,
111
+ `Unknown target "${what}". Channels: ${CHANNEL_PLATFORMS.join(", ")}, meta-ads. Keys: ${SENSOR_SERVICES.join(", ")}. Model keys: ${PROVIDERS.join(", ")}.`,
92
112
  );
93
113
  }
94
114
 
@@ -214,10 +234,19 @@ async function connectChannel(ctx, platform) {
214
234
  const returnUrl = `${ctx.appUrl()}/connected`;
215
235
  const auth = ctx.auth();
216
236
  const pageId = ctx.flags.page;
217
- if (pageId && platform !== "facebook") throw new UsageError("--page is only supported for Facebook connections.");
218
- const pending = pendingGrant(ctx, `channel:${auth.apiUrl}:${auth.token}:${account}:${platform}:${pageId ?? ""}`);
237
+ const adAccountId = ctx.flags["ad-account"];
238
+ const pixelId = ctx.flags.dataset;
239
+ if (pageId && platform !== "facebook" && platform !== "meta_ads") throw new UsageError("--page is only supported for Facebook and Meta Ads connections.");
240
+ if (adAccountId && !["meta_ads", "google_ads", "tiktok_ads"].includes(platform)) throw new UsageError("--ad-account is only supported for Meta Ads, Google Ads and TikTok Ads connections.");
241
+ if (pixelId && platform !== "meta_ads") throw new UsageError("--dataset is only supported for Meta Ads connections.");
242
+ const selection = {
243
+ ...(pageId ? { pageId: String(pageId) } : {}),
244
+ ...(adAccountId ? { adAccountId: String(adAccountId) } : {}),
245
+ ...(pixelId ? { pixelId: String(pixelId) } : {}),
246
+ };
247
+ const pending = pendingGrant(ctx, `channel:${auth.apiUrl}:${auth.token}:${account}:${platform}:${pageId ?? ""}:${adAccountId ?? ""}:${pixelId ?? ""}`);
219
248
  const previous = pending.read();
220
- const { url, pendingId } = previous ?? await ctx.api().post(`/api/channels/connect/${platform}`, { accountSlug: account, returnUrl, ...(pageId ? { pageId } : {}) });
249
+ const { url, pendingId } = previous ?? await ctx.api().post(`/api/channels/connect/${platform}`, { accountSlug: account, returnUrl, ...selection });
221
250
  if (!url) throw new ApiError(500, "The server returned no authorize URL.");
222
251
  if (!previous && pendingId) pending.write({ url, pendingId, expiresAt: Date.now() + PENDING_TIMEOUT_MS });
223
252
 
@@ -243,7 +272,8 @@ async function connectChannel(ctx, platform) {
243
272
  const s = last?.status ?? "pending";
244
273
  if (s === "connected") {
245
274
  pending.clear();
246
- const handle = last.channel?.displayName ? `@${last.channel.displayName}` : label(platform);
275
+ const handle = !last.channel?.displayName ? label(platform)
276
+ : ["meta_ads", "google_ads", "tiktok_ads"].includes(platform) ? last.channel.displayName : `@${last.channel.displayName}`;
247
277
  if (ctx.json) ctx.out({ ok: true, status: s, channel: last.channel, account });
248
278
  else ctx.print(` ${C.ok(OK)} ${handle} connected to ${account}`);
249
279
  return 0;
@@ -253,7 +283,7 @@ async function connectChannel(ctx, platform) {
253
283
  ? `${last.error || "That login is already connected to another account."} Move it in Settings → Accounts, or connect a different login.`
254
284
  : s === "expired" || s === "pending"
255
285
  ? "Timed out waiting for the browser (10 minutes). Run the command again."
256
- : last?.error || `Connect ${s}.`;
286
+ : cliFlags(last?.error) || `Connect ${s}.`;
257
287
  if (s !== "pending") pending.clear();
258
288
  if (ctx.json) {
259
289
  ctx.out({ ok: false, status: s, error: message, channel: last?.channel ?? null });
@@ -263,7 +293,12 @@ async function connectChannel(ctx, platform) {
263
293
  }
264
294
 
265
295
  function label(platform) {
266
- return { instagram: "Instagram", tiktok: "TikTok", youtube: "YouTube", linkedin: "LinkedIn", facebook: "Facebook", pinterest: "Pinterest", x: "X" }[platform] ?? platform;
296
+ return { instagram: "Instagram", tiktok: "TikTok", youtube: "YouTube", linkedin: "LinkedIn", facebook: "Facebook", pinterest: "Pinterest", x: "X", meta_ads: "Meta Ads", google_ads: "Google Ads", tiktok_ads: "TikTok Ads", tiktok_accounts: "TikTok comments" }[platform] ?? platform;
297
+ }
298
+
299
+ /** The server names the fields to re-run with; here they are flags. */
300
+ function cliFlags(message) {
301
+ return message?.replace(/\bpageId\b/g, "--page").replace(/\badAccountId\b/g, "--ad-account");
267
302
  }
268
303
 
269
304
  // ─── sensors (keys) ──────────────────────────────────────────────────────────
@@ -283,13 +318,25 @@ export function fileOrValue(arg, cwd) {
283
318
  return String(arg);
284
319
  }
285
320
 
286
- /** Flags → the PATCH body for one service. Reads files; never echoes them. */
287
- export function sensorPatch(service, flags, cwd) {
321
+ // Services whose credential is a downloaded file (a .p8, a service-account
322
+ // JSON). There is nothing to copy, so the clipboard is not offered for them.
323
+ const FILE_KEY_SERVICES = new Set(["asc", "play", "gsc", "ga4"]);
324
+
325
+ /**
326
+ * Flags → the PATCH body for one service. Reads files; never echoes them.
327
+ * `pasted` is a key already read from the clipboard (`--key-from-clipboard`);
328
+ * it takes the place of the --key / --token file.
329
+ */
330
+ export function sensorPatch(service, flags, cwd, pasted) {
288
331
  const need = (name) => {
289
332
  const v = flags[name];
290
- if (!v || v === true) throw new UsageError(`sprid connect ${service} needs --${name}`);
333
+ if (!v || v === true) {
334
+ const hint = !FILE_KEY_SERVICES.has(service) && (name === "key" || name === "token") ? ` (or --key-from-clipboard)` : "";
335
+ throw new UsageError(`sprid connect ${service} needs --${name}${hint}`);
336
+ }
291
337
  return String(v);
292
338
  };
339
+ const secret = (name) => pasted ?? fileOrValue(need(name), cwd);
293
340
  switch (service) {
294
341
  case "asc":
295
342
  return {
@@ -307,7 +354,7 @@ export function sensorPatch(service, flags, cwd) {
307
354
  secrets: { gscServiceAccountJson: readKeyFile(need("key"), cwd) },
308
355
  };
309
356
  case "posthog": {
310
- const body = { posthogProjectId: need("project"), secrets: { posthogApiKey: fileOrValue(need("key"), cwd) } };
357
+ const body = { posthogProjectId: need("project"), secrets: { posthogApiKey: secret("key") } };
311
358
  if (flags.host && flags.host !== true) {
312
359
  const host = String(flags.host);
313
360
  body.posthogHost = host === "eu" || host === "us" ? `https://${host}.posthog.com` : host;
@@ -331,7 +378,7 @@ export function sensorPatch(service, flags, cwd) {
331
378
  case "plausible": {
332
379
  const body = {
333
380
  plausibleSiteId: String(need("site")).replace(/^https?:\/\//, "").replace(/\/+$/, ""),
334
- secrets: { plausibleApiKey: fileOrValue(need("key"), cwd) },
381
+ secrets: { plausibleApiKey: secret("key") },
335
382
  };
336
383
  if (flags.host && flags.host !== true) body.plausibleHost = String(flags.host).replace(/\/+$/, "");
337
384
  if (flags.use) body.trafficProvider = "plausible";
@@ -340,7 +387,7 @@ export function sensorPatch(service, flags, cwd) {
340
387
  case "umami": {
341
388
  const body = {
342
389
  umamiWebsiteId: String(need("site")),
343
- secrets: { umamiApiKey: fileOrValue(need("key"), cwd) },
390
+ secrets: { umamiApiKey: secret("key") },
344
391
  };
345
392
  // A self-hosted Umami serves its API under /api. Leaving that off is the
346
393
  // commonest setup mistake, and it fails as a 404 on every read, so add
@@ -353,41 +400,65 @@ export function sensorPatch(service, flags, cwd) {
353
400
  return body;
354
401
  }
355
402
  case "revenuecat":
356
- return { revenuecatProjectId: need("project"), secrets: { revenuecatApiKey: fileOrValue(need("key"), cwd) } };
403
+ return { revenuecatProjectId: need("project"), secrets: { revenuecatApiKey: secret("key") } };
357
404
  // Stripe and Paddle need no id: the key names the account. Polar takes an
358
405
  // optional organization id (an org token already implies one), Lemon
359
406
  // Squeezy needs its store id.
360
407
  case "stripe":
361
- return { secrets: { stripeApiKey: fileOrValue(need("key"), cwd) } };
408
+ return { secrets: { stripeApiKey: secret("key") } };
362
409
  case "polar": {
363
- const body = { secrets: { polarApiKey: fileOrValue(need("key"), cwd) } };
410
+ const body = { secrets: { polarApiKey: secret("key") } };
364
411
  if (flags.org && flags.org !== true) body.polarOrganizationId = String(flags.org);
365
412
  return body;
366
413
  }
367
414
  case "lemonsqueezy":
368
415
  return {
369
416
  lemonSqueezyStoreId: String(need("store")),
370
- secrets: { lemonSqueezyApiKey: fileOrValue(need("key"), cwd) },
417
+ secrets: { lemonSqueezyApiKey: secret("key") },
371
418
  };
372
419
  case "paddle": {
373
- const body = { secrets: { paddleApiKey: fileOrValue(need("key"), cwd) } };
420
+ const body = { secrets: { paddleApiKey: secret("key") } };
374
421
  if (flags.env && flags.env !== true) body.paddleEnvironment = String(flags.env) === "sandbox" ? "sandbox" : "live";
375
422
  return body;
376
423
  }
377
424
  case "cloudflare":
378
- return { cloudflareZoneId: need("zone"), secrets: { cloudflareApiToken: fileOrValue(need("token"), cwd) } };
425
+ return { cloudflareZoneId: need("zone"), secrets: { cloudflareApiToken: secret("token") } };
379
426
  default:
380
427
  throw new UsageError(`Unknown key type "${service}"`);
381
428
  }
382
429
  }
383
430
 
431
+ /**
432
+ * `--key-from-clipboard`: the key a provider just showed, read at run time.
433
+ * Refused with a file flag beside it (which one would win is a guess) and for
434
+ * services whose credential is a download.
435
+ */
436
+ function pastedKey(ctx, service, label) {
437
+ if (!ctx.flags["key-from-clipboard"]) return undefined;
438
+ if (FILE_KEY_SERVICES.has(service)) {
439
+ throw new UsageError(`${label} uses a downloaded key file. Pass --key <path to the file> instead of --key-from-clipboard.`);
440
+ }
441
+ if ((ctx.flags.key && ctx.flags.key !== true) || (ctx.flags.token && ctx.flags.token !== true)) {
442
+ throw new UsageError("Pass either --key-from-clipboard or a key file, not both.");
443
+ }
444
+ return readClipboardKey(`the ${label} key`);
445
+ }
446
+
447
+ function clearPasted(ctx, pasted) {
448
+ if (pasted === undefined) return;
449
+ const cleared = clearClipboard();
450
+ if (!ctx.json) ctx.print(C.dim(cleared ? " Clipboard cleared." : " Could not clear the clipboard. Copy something else over the key."));
451
+ }
452
+
384
453
  async function connectSensor(ctx, service) {
385
454
  if (ctx.flags.clear) return clearSensor(ctx, service);
386
- const body = sensorPatch(service, ctx.flags, ctx.cwd);
455
+ const pasted = pastedKey(ctx, service, SENSOR_LABEL[service]);
456
+ const body = sensorPatch(service, ctx.flags, ctx.cwd, pasted);
387
457
  const wid = await ctx.workspaceId();
388
458
  const profile = await resolveProfile(ctx, wid);
389
459
  const updated = await patchProfile(ctx, wid, profile.id, body);
390
460
  report(ctx, updated, Object.keys(body.secrets), `${SENSOR_LABEL[service]} key`, wid, service);
461
+ clearPasted(ctx, pasted);
391
462
  return 0;
392
463
  }
393
464
 
@@ -432,9 +503,10 @@ function report(ctx, profile, keys, what, workspaceId, service) {
432
503
  */
433
504
  async function connectProvider(ctx, provider) {
434
505
  if (ctx.flags.clear) return clearProvider(ctx, provider);
506
+ const pasted = pastedKey(ctx, provider, provider);
435
507
  const raw = ctx.flags.key;
436
- if (!raw || raw === true) throw new UsageError(`sprid connect ${provider} --key <file or key>`);
437
- const apiKey = fileOrValue(raw, ctx.cwd);
508
+ if (pasted === undefined && (!raw || raw === true)) throw new UsageError(`sprid connect ${provider} --key <file or key> (or --key-from-clipboard)`);
509
+ const apiKey = pasted ?? fileOrValue(raw, ctx.cwd);
438
510
  if (apiKey.length < 8) throw new UsageError("That key is too short to be one.");
439
511
  const wid = await ctx.workspaceId();
440
512
  let created;
@@ -452,6 +524,7 @@ async function connectProvider(ctx, provider) {
452
524
  ctx.print(` ${C.ok(OK)} ${provider} key set on the workspace${hint ? ` ${C.dim(hint)}` : ""}`);
453
525
  if (provider === "anthropic" || provider === "openai") ctx.print(C.dim(" Review replies and other drafting now run on your own key."));
454
526
  }
527
+ clearPasted(ctx, pasted);
455
528
  return 0;
456
529
  }
457
530
 
@@ -556,12 +629,12 @@ async function fromLocal(ctx) {
556
629
  * server refuses to forget a channel that has published.
557
630
  */
558
631
  export async function disconnect(ctx) {
559
- const [platform] = ctx.positionals;
632
+ const platform = channelPlatform(ctx.positionals[0]);
560
633
  const account = ctx.flags.account;
561
634
  const forget = !!ctx.flags.forget;
562
- if (!platform || !CHANNEL_PLATFORMS.includes(platform)) {
635
+ if (!platform) {
563
636
  throw new UsageError(
564
- `sprid disconnect <${CHANNEL_PLATFORMS.join("|")}> --account <slug> [--forget]`,
637
+ `sprid disconnect <${[...CHANNEL_PLATFORMS, "meta-ads"].join("|")}> --account <slug> [--forget]`,
565
638
  );
566
639
  }
567
640
  if (!account) throw new UsageError(`sprid disconnect ${platform} --account <slug>`);