@sprid/cli 0.1.6 → 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,20 @@
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
+
6
20
  ## 0.1.6 - 2026-09-24
7
21
 
8
22
  - `sprid connect <service> --key-from-clipboard` reads a copied key from 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.6",
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,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
+ }
@@ -22,6 +22,25 @@ import { confirm } from "./reviews.mjs";
22
22
  import { expandHome, listProfiles, patchProfile, readKeyFile, readLocalApp, resolveProfile } from "../profiles.mjs";
23
23
 
24
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]);
25
44
  export const SENSOR_SERVICES = [
26
45
  "asc",
27
46
  "play",
@@ -85,11 +104,11 @@ export async function connect(ctx) {
85
104
  const [what] = ctx.positionals;
86
105
  if (ctx.flags["from-local"]) return fromLocal(ctx);
87
106
  if (!what) return wizard(ctx);
88
- if (CHANNEL_PLATFORMS.includes(what)) return connectChannel(ctx, what);
107
+ if (channelPlatform(what)) return connectChannel(ctx, channelPlatform(what));
89
108
  if (SENSOR_SERVICES.includes(what)) return connectSensor(ctx, what);
90
109
  if (PROVIDERS.includes(what)) return connectProvider(ctx, what);
91
110
  throw new UsageError(
92
- `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(", ")}.`,
93
112
  );
94
113
  }
95
114
 
@@ -215,10 +234,19 @@ async function connectChannel(ctx, platform) {
215
234
  const returnUrl = `${ctx.appUrl()}/connected`;
216
235
  const auth = ctx.auth();
217
236
  const pageId = ctx.flags.page;
218
- if (pageId && platform !== "facebook") throw new UsageError("--page is only supported for Facebook connections.");
219
- 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 ?? ""}`);
220
248
  const previous = pending.read();
221
- 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 });
222
250
  if (!url) throw new ApiError(500, "The server returned no authorize URL.");
223
251
  if (!previous && pendingId) pending.write({ url, pendingId, expiresAt: Date.now() + PENDING_TIMEOUT_MS });
224
252
 
@@ -244,7 +272,8 @@ async function connectChannel(ctx, platform) {
244
272
  const s = last?.status ?? "pending";
245
273
  if (s === "connected") {
246
274
  pending.clear();
247
- 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}`;
248
277
  if (ctx.json) ctx.out({ ok: true, status: s, channel: last.channel, account });
249
278
  else ctx.print(` ${C.ok(OK)} ${handle} connected to ${account}`);
250
279
  return 0;
@@ -254,7 +283,7 @@ async function connectChannel(ctx, platform) {
254
283
  ? `${last.error || "That login is already connected to another account."} Move it in Settings → Accounts, or connect a different login.`
255
284
  : s === "expired" || s === "pending"
256
285
  ? "Timed out waiting for the browser (10 minutes). Run the command again."
257
- : last?.error || `Connect ${s}.`;
286
+ : cliFlags(last?.error) || `Connect ${s}.`;
258
287
  if (s !== "pending") pending.clear();
259
288
  if (ctx.json) {
260
289
  ctx.out({ ok: false, status: s, error: message, channel: last?.channel ?? null });
@@ -264,7 +293,12 @@ async function connectChannel(ctx, platform) {
264
293
  }
265
294
 
266
295
  function label(platform) {
267
- 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");
268
302
  }
269
303
 
270
304
  // ─── sensors (keys) ──────────────────────────────────────────────────────────
@@ -595,12 +629,12 @@ async function fromLocal(ctx) {
595
629
  * server refuses to forget a channel that has published.
596
630
  */
597
631
  export async function disconnect(ctx) {
598
- const [platform] = ctx.positionals;
632
+ const platform = channelPlatform(ctx.positionals[0]);
599
633
  const account = ctx.flags.account;
600
634
  const forget = !!ctx.flags.forget;
601
- if (!platform || !CHANNEL_PLATFORMS.includes(platform)) {
635
+ if (!platform) {
602
636
  throw new UsageError(
603
- `sprid disconnect <${CHANNEL_PLATFORMS.join("|")}> --account <slug> [--forget]`,
637
+ `sprid disconnect <${[...CHANNEL_PLATFORMS, "meta-ads"].join("|")}> --account <slug> [--forget]`,
604
638
  );
605
639
  }
606
640
  if (!account) throw new UsageError(`sprid disconnect ${platform} --account <slug>`);
@@ -0,0 +1,60 @@
1
+ // sprid events: the milestones Insights draws on its graphs. A change that
2
+ // can move a number gets logged with the graphs it explains (--areas) and why,
3
+ // or the next read of that graph cannot be explained.
4
+
5
+ import { UsageError } from "../args.mjs";
6
+ import { C, OK } from "../format.mjs";
7
+ import { callTool, listFlag } from "../tools.mjs";
8
+
9
+ const KINDS = ["release", "listing", "screenshots", "submission", "note"];
10
+ const USAGE = `sprid events [list] [--days N] | add --kind ${KINDS.join("|")} --label "…" [--app <slug>] [--areas social,store,…] [--why "…"] [--at ISO]`;
11
+
12
+ export async function events(ctx) {
13
+ const [sub] = ctx.positionals;
14
+ if (!sub || sub === "list") return list(ctx);
15
+ if (sub === "add") return add(ctx);
16
+ throw new UsageError(USAGE);
17
+ }
18
+
19
+ function workspaceArg(ctx) {
20
+ return ctx.flags.workspace ? { workspace: String(ctx.flags.workspace) } : {};
21
+ }
22
+
23
+ async function list(ctx) {
24
+ const days = ctx.flags.days ? Number(ctx.flags.days) : undefined;
25
+ const res = await callTool(ctx, "list_events", { ...workspaceArg(ctx), ...(days ? { days } : {}) });
26
+ if (ctx.json) {
27
+ ctx.out(res);
28
+ return 0;
29
+ }
30
+ const rows = Array.isArray(res) ? res : res?.events ?? [];
31
+ if (rows.length === 0) {
32
+ ctx.print(" No events in this window.");
33
+ return 0;
34
+ }
35
+ for (const e of rows) {
36
+ const areas = e.meta?.areas?.length ? C.dim(` [${e.meta.areas.join(", ")}]`) : "";
37
+ ctx.print(` ${String(e.at ?? e.createdAt ?? "").slice(0, 10)} ${e.kind.padEnd(11)} ${e.label}${areas}`);
38
+ if (e.meta?.why) ctx.print(C.dim(` ${e.meta.why}`));
39
+ }
40
+ return 0;
41
+ }
42
+
43
+ async function add(ctx) {
44
+ const kind = ctx.flags.kind;
45
+ const label = ctx.flags.label;
46
+ if (!KINDS.includes(kind) || typeof label !== "string") throw new UsageError(USAGE);
47
+ const args = {
48
+ ...workspaceArg(ctx),
49
+ kind,
50
+ label,
51
+ ...(ctx.flags.app ? { app: String(ctx.flags.app) } : {}),
52
+ ...(listFlag(ctx.flags.areas) ? { areas: listFlag(ctx.flags.areas) } : {}),
53
+ ...(typeof ctx.flags.why === "string" ? { why: ctx.flags.why } : {}),
54
+ ...(typeof ctx.flags.at === "string" ? { at: ctx.flags.at } : {}),
55
+ };
56
+ const res = await callTool(ctx, "add_event", args);
57
+ if (ctx.json) ctx.out(res);
58
+ else ctx.print(` ${C.ok(OK)} Logged ${kind}: ${label}`);
59
+ return 0;
60
+ }
@@ -0,0 +1,124 @@
1
+ // sprid inbox: comments on posts and ads plus store reviews, as the one ranked
2
+ // queue the app's Inbox shows. Replying is public, so `reply` shows the text
3
+ // and asks, the way `reviews reply` does. Store reviews are replied to with
4
+ // `sprid reviews reply`; here they can only be filed.
5
+
6
+ import { UsageError } from "../args.mjs";
7
+ import { C, OK } from "../format.mjs";
8
+ import { callTool, listFlag } from "../tools.mjs";
9
+ import { confirm, stars, truncate } from "./reviews.mjs";
10
+
11
+ const USAGE = "sprid inbox [--view needs_you|all|done] [--kind all|comment|review] [--accounts a,b] [--app <slug>] [--limit N] | draft <commentId> | reply <commentId> --text \"…\" [--yes] | done|flag|reopen <c:id|r:id>";
12
+ const SOURCE = { instagram: "Instagram", youtube: "YouTube", app_store: "App Store", google_play: "Google Play" };
13
+
14
+ export async function inbox(ctx) {
15
+ const [sub, ref] = ctx.positionals;
16
+ if (!sub) return list(ctx);
17
+ if (sub === "draft") return draft(ctx, ref);
18
+ if (sub === "reply") return reply(ctx, ref);
19
+ if (sub === "done" || sub === "flag" || sub === "reopen") return file(ctx, sub, ref);
20
+ throw new UsageError(USAGE);
21
+ }
22
+
23
+ async function list(ctx) {
24
+ const args = {
25
+ ...(ctx.flags.workspace ? { workspace: String(ctx.flags.workspace) } : {}),
26
+ ...(listFlag(ctx.flags.accounts ?? ctx.flags.account) ? { accounts: listFlag(ctx.flags.accounts ?? ctx.flags.account) } : {}),
27
+ ...(ctx.flags.app ? { app: String(ctx.flags.app) } : {}),
28
+ ...(typeof ctx.flags.view === "string" ? { view: ctx.flags.view } : {}),
29
+ ...(typeof ctx.flags.kind === "string" ? { kind: ctx.flags.kind } : {}),
30
+ ...(typeof ctx.flags.intent === "string" ? { intent: ctx.flags.intent } : {}),
31
+ ...(ctx.flags.limit ? { limit: Number(ctx.flags.limit) } : {}),
32
+ };
33
+ const res = await callTool(ctx, "list_inbox", args);
34
+ if (ctx.json) {
35
+ ctx.out(res);
36
+ return 0;
37
+ }
38
+ const items = res?.items ?? [];
39
+ if (items.length === 0) {
40
+ ctx.print(" Nothing here.");
41
+ return 0;
42
+ }
43
+ ctx.print(items.map(render).join("\n\n"));
44
+ if (res.more) ctx.print(C.dim(`\n ${res.more} more. Raise --limit to see them.`));
45
+ return 0;
46
+ }
47
+
48
+ function render(item) {
49
+ const where = item.account?.slug ?? item.app?.slug ?? "";
50
+ const flag = item.urgent ? C.bad(" urgent") : item.overdue ? C.warn(" overdue") : "";
51
+ const rating = item.kind === "review" && item.rating ? ` ${stars(item.rating)}` : "";
52
+ const head = ` ${item.kind === "comment" ? "c" : "r"}:${item.id} ${where} ${SOURCE[item.source] ?? item.source}${item.onAd ? " ad" : ""} ${item.intent}${rating}${flag} ${C.dim(String(item.at ?? "").slice(0, 10))}`;
53
+ const lines = [head];
54
+ const text = [item.title, item.text].filter(Boolean).join(" · ");
55
+ lines.push(` ${item.author ? `${item.author}: ` : ""}${truncate(text, 200)}`);
56
+ if (item.post?.title || item.post?.caption) lines.push(C.dim(` on: ${truncate(item.post.title ?? item.post.caption, 80)}`));
57
+ lines.push(C.dim(item.kind === "comment" ? ` sprid inbox draft ${item.id}` : ` sprid reviews draft ${item.id}`));
58
+ return lines.join("\n");
59
+ }
60
+
61
+ /** `c:12` or a bare `12` is a comment; `r:41` is a store review. */
62
+ function parseRef(ref, usage) {
63
+ const m = String(ref ?? "").match(/^(?:(c|r):)?([1-9]\d*)$/i);
64
+ if (!m) throw new UsageError(usage);
65
+ return { kind: (m[1] ?? "c").toLowerCase() === "r" ? "review" : "comment", id: Number(m[2]) };
66
+ }
67
+
68
+ async function draft(ctx, ref) {
69
+ const { kind, id } = parseRef(ref, "sprid inbox draft <commentId>");
70
+ if (kind === "review") throw new UsageError(`Store reviews are drafted with: sprid reviews draft ${id}`);
71
+ const res = await callTool(ctx, "draft_comment_reply", { commentId: id, ...(ctx.flags.count ? { count: Number(ctx.flags.count) } : {}) });
72
+ if (ctx.json) {
73
+ ctx.out(res);
74
+ return 0;
75
+ }
76
+ const suggestions = res?.suggestions ?? [];
77
+ if (res?.shouldEscalate) ctx.print(C.warn(" This one should be answered by a person, not a template."));
78
+ if (res?.note) ctx.print(C.dim(` ${res.note}`));
79
+ suggestions.forEach((s, i) => ctx.print(` ${i + 1}. ${typeof s === "string" ? s : s.text ?? JSON.stringify(s)}`));
80
+ if (suggestions.length) ctx.print(C.dim(`\n Send one: sprid inbox reply ${id} --text "…"`));
81
+ return 0;
82
+ }
83
+
84
+ async function reply(ctx, ref) {
85
+ const { kind, id } = parseRef(ref, "sprid inbox reply <commentId> --text \"…\" [--yes]");
86
+ if (kind === "review") throw new UsageError(`Store reviews are replied to with: sprid reviews reply ${id}`);
87
+ const text = typeof ctx.flags.text === "string" ? ctx.flags.text.trim() : "";
88
+ if (!text) throw new UsageError(`Pass the reply with --text. Suggestions: sprid inbox draft ${id}`);
89
+ if (text.length > 500) throw new UsageError(`${text.length} characters; a comment reply allows 500.`);
90
+ if (!ctx.flags.yes) {
91
+ const msg = "Replying is public. Re-run with --yes to send without the prompt.";
92
+ if (ctx.json || !ctx.stdin?.isTTY) {
93
+ if (ctx.json) ctx.out({ ok: false, error: msg, commentId: id, text });
94
+ else {
95
+ ctx.print(` Will reply to comment ${id}:\n ${text}`);
96
+ ctx.warn(` ✗ ${msg}`);
97
+ }
98
+ return 2;
99
+ }
100
+ ctx.print(` Will reply publicly to comment ${id}:\n\n ${text}\n`);
101
+ if (!(await confirm(ctx, " Send it? [y/N] "))) {
102
+ ctx.print(" Not sent.");
103
+ return 1;
104
+ }
105
+ }
106
+ const res = await callTool(ctx, "reply_comment", { commentId: id, text });
107
+ if (ctx.json) ctx.out(res);
108
+ else ctx.print(` ${C.ok(OK)} Reply sent to comment ${id}.`);
109
+ return 0;
110
+ }
111
+
112
+ async function file(ctx, verb, ref) {
113
+ const { kind, id } = parseRef(ref, `sprid inbox ${verb} <c:id|r:id>`);
114
+ let res;
115
+ if (kind === "review") {
116
+ if (verb === "flag") throw new UsageError("Store reviews cannot be flagged; file them with done or put them back with reopen.");
117
+ res = await callTool(ctx, "dismiss_store_review", { reviewId: id, dismissed: verb === "done" });
118
+ } else {
119
+ res = await callTool(ctx, "update_comment", { commentId: id, state: verb === "reopen" ? "needs_you" : verb });
120
+ }
121
+ if (ctx.json) ctx.out(res);
122
+ else ctx.print(` ${C.ok(OK)} ${kind === "review" ? "Review" : "Comment"} ${id} ${verb === "done" ? "filed" : verb === "flag" ? "flagged" : "back in Needs you"}.`);
123
+ return 0;
124
+ }
@@ -2,8 +2,10 @@
2
2
  import { fork } from "node:child_process";
3
3
  import { run as runPost } from "../post/cli.mjs";
4
4
  import { remotePost } from './studio.mjs';
5
+ import { publishing, PUBLISHING_VERBS } from './publishing.mjs';
5
6
 
6
7
  export async function post(ctx) {
8
+ if (PUBLISHING_VERBS.includes(ctx.positionals[0])) return publishing(ctx);
7
9
  if (await remotePost(ctx)) return 0;
8
10
  return localPost(ctx);
9
11
  }