rankcontrol 0.2.0 → 0.7.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/src/cli.mjs CHANGED
@@ -12,13 +12,16 @@ const fail = (err) => {
12
12
  const list = (val) => val.split(",").map((s) => s.trim()).filter(Boolean);
13
13
 
14
14
  export function runCli(argv) {
15
+ const pkg = JSON.parse(
16
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
17
+ );
15
18
  const program = new Command();
16
19
  program
17
20
  .name("rankcontrol")
18
21
  .description(
19
22
  "RankControl from the terminal. Auth: `rankcontrol login` (browser approval) or export RANKCONTROL_API_KEY=rctrl_pk_..."
20
23
  )
21
- .version("0.2.0");
24
+ .version(pkg.version);
22
25
 
23
26
  program
24
27
  .command("mcp")
@@ -30,11 +33,10 @@ export function runCli(argv) {
30
33
 
31
34
  program
32
35
  .command("login")
33
- .description("Authenticate via your browser (approves a scoped API key into ~/.rankcontrol)")
34
- .option("--scopes <a,b,c>", "Scopes to request (default: reads + write:content + write:publish)")
35
- .action(async (opts) => {
36
+ .description("Authenticate via your browser (full workspace access; billing stays in the dashboard)")
37
+ .action(async () => {
36
38
  const { login } = await import("./login.mjs");
37
- await login({ scopes: opts.scopes ? list(opts.scopes) : undefined }).catch(fail);
39
+ await login().catch(fail);
38
40
  });
39
41
 
40
42
  program
@@ -50,12 +52,53 @@ export function runCli(argv) {
50
52
  .description("AI pipeline last 30 days: crawls, AI visits, AI leads")
51
53
  .action(() => api.overviewFunnel().then(out).catch(fail));
52
54
 
55
+ program
56
+ .command("leads")
57
+ .description("Captured leads with source attribution (AI model, query, page)")
58
+ .option("--limit <n>", "Max rows, up to 500", "100")
59
+ .action((opts) => api.leads(Number(opts.limit)).then(out).catch(fail));
60
+
61
+ program
62
+ .command("traffic")
63
+ .description("Traffic overview: page views, visitors, sessions, bounce rate, time on page")
64
+ .option("--days <n>", "7, 30 or 90", "30")
65
+ .action((opts) => api.trafficOverview(Number(opts.days)).then(out).catch(fail));
66
+
67
+ program
68
+ .command("score")
69
+ .description("Composite visibility score: 50% AI citation rate + 30% Google + 20% Bing rank share, with subscores")
70
+ .action(() => api.visibilityScore().then(out).catch(fail));
71
+
53
72
  program
54
73
  .command("visibility")
55
74
  .description("Daily AI visibility score trend from the stored weekly citation checks")
56
75
  .option("--days <n>", "30, 60 or 90", "30")
57
76
  .action((opts) => api.visibilityTrend(Number(opts.days)).then(out).catch(fail));
58
77
 
78
+ program
79
+ .command("sov")
80
+ .description("Share of voice: your citation rate vs top competitors, plus your share of all brand appearances")
81
+ .option("--days <n>", "Window in days, 7-90", "30")
82
+ .action((opts) => api.shareOfVoice(Number(opts.days)).then(out).catch(fail));
83
+
84
+ program
85
+ .command("sources")
86
+ .description("Domains AI answers cite for your tracked queries, typed brand/competitive/ugc/editorial")
87
+ .option("--days <n>", "Window in days, 7-90", "30")
88
+ .action((opts) => api.citationSources(Number(opts.days)).then(out).catch(fail));
89
+
90
+ program
91
+ .command("sentiment")
92
+ .description("How AI answers frame your brand when cited: positive/neutral/negative with recent receipts")
93
+ .option("--days <n>", "Window in days, 7-90", "30")
94
+ .action((opts) => api.citationSentiment(Number(opts.days)).then(out).catch(fail));
95
+
96
+ program
97
+ .command("optimizer")
98
+ .description("Published pages ranked by citability score, worst first, with their open fixes")
99
+ .option("--limit <n>", "Max rows", "25")
100
+ .action((opts) => api.optimizer(Number(opts.limit)).then(out).catch(fail));
101
+
59
102
  program
60
103
  .command("citations")
61
104
  .description("Recent AI citation checks")
@@ -68,11 +111,254 @@ export function runCli(argv) {
68
111
  .catch(fail)
69
112
  );
70
113
 
114
+ program
115
+ .command("queries")
116
+ .description("List the tracked queries checked weekly across the AI engines")
117
+ .action(() => api.trackedQueries().then(out).catch(fail));
118
+
119
+ program
120
+ .command("crawler-access")
121
+ .description("Daily AI-crawler reachability probe (a diagnostic, not a metric): is the site's edge or robots.txt blocking GPTBot/ClaudeBot")
122
+ .action(() => api.crawlerAccess().then(out).catch(fail));
123
+
124
+ program
125
+ .command("analytics-sources")
126
+ .description("Per-org analytics sources: selections, qualified options, Cloudflare/plugin/embed install state")
127
+ .action(() => api.analyticsSources().then(out).catch(fail));
128
+
129
+ program
130
+ .command("analytics-set-source")
131
+ .description("Select the writer for a data type (validated against what the setup supports)")
132
+ .argument("<dataType>", "humanTraffic or aiCrawlers")
133
+ .argument("<source>", "embed, wordpress_plugin, cloudflare or none")
134
+ .action((dataType, source) =>
135
+ api.setAnalyticsSource(dataType, source).then(out).catch(fail)
136
+ );
137
+
138
+ program
139
+ .command("analytics-activate")
140
+ .description("Turn on the Analytics or Reports screen for this workspace")
141
+ .argument("<screen>", "analytics or reports")
142
+ .action((screen) => api.activateScreen(screen).then(out).catch(fail));
143
+
144
+ program
145
+ .command("cloudflare-connect")
146
+ .description("Print the Cloudflare OAuth URL to connect read-only crawler analytics")
147
+ .action(() =>
148
+ api
149
+ .cloudflareConnectUrl()
150
+ .then((d) => out(d?.url ?? d))
151
+ .catch(fail)
152
+ );
153
+
154
+ program
155
+ .command("cloudflare-zones")
156
+ .description("List Cloudflare zones visible to the connected grant")
157
+ .action(() => api.cloudflareZones().then(out).catch(fail));
158
+
159
+ program
160
+ .command("cloudflare-zone")
161
+ .description("Pick the polled zone (also selects Cloudflare as the AI-crawler source and starts the first sync)")
162
+ .argument("<zoneId>", "Zone id from cloudflare-zones")
163
+ .argument("<zoneName>", "Zone name, e.g. example.com")
164
+ .action((zoneId, zoneName) =>
165
+ api.cloudflareSelectZone(zoneId, zoneName).then(out).catch(fail)
166
+ );
167
+
168
+ program
169
+ .command("framer-install-embed")
170
+ .description("Install site-wide tracking on the Framer project (dry run without --confirm; installing also publishes the Framer site)")
171
+ .option("--integration <id>", "Integration id (defaults to the org's Framer integration)")
172
+ .option("--confirm", "Actually install", false)
173
+ .action((opts) =>
174
+ api
175
+ .framerInstallEmbed({
176
+ ...(opts.integration ? { integrationId: opts.integration } : {}),
177
+ confirm: Boolean(opts.confirm),
178
+ })
179
+ .then(out)
180
+ .catch(fail)
181
+ );
182
+
183
+ program
184
+ .command("webflow-custom-code")
185
+ .description("Install the tracking loader via Webflow's Custom Code API (dry run without --confirm; live on the customer's next site publish)")
186
+ .option("--integration <id>", "Integration id (defaults to the org's Webflow integration)")
187
+ .option("--token <token>", "New site token with CMS + Custom code scopes (replaces the stored one)")
188
+ .option("--confirm", "Actually install", false)
189
+ .action((opts) =>
190
+ api
191
+ .webflowInstallCustomCode({
192
+ ...(opts.integration ? { integrationId: opts.integration } : {}),
193
+ ...(opts.token ? { apiToken: opts.token } : {}),
194
+ confirm: Boolean(opts.confirm),
195
+ })
196
+ .then(out)
197
+ .catch(fail)
198
+ );
199
+
200
+ program
201
+ .command("query-add")
202
+ .description("Add a query to the tracking pool (tracks immediately when a plan slot is free)")
203
+ .argument("<queryText>", "The search/AI query to track")
204
+ .action((queryText) => api.addQuery(queryText).then(out).catch(fail));
205
+
206
+ program
207
+ .command("query-remove")
208
+ .description("Delete a query from the tracking pool")
209
+ .argument("<queryId>", "Query id (from the queries command)")
210
+ .action((queryId) => api.removeQuery(queryId).then(out).catch(fail));
211
+
212
+ program
213
+ .command("query-track")
214
+ .description("Pause or resume weekly checks on a query. Tracking uses a plan slot; pausing frees it")
215
+ .argument("<queryId>", "Query id (from the queries command)")
216
+ .argument("<state>", "on | off")
217
+ .action((queryId, state) =>
218
+ api.setQueryTracking(queryId, state === "on").then(out).catch(fail)
219
+ );
220
+
221
+ program
222
+ .command("query-edit")
223
+ .description("Rewrite a tracked query's text")
224
+ .argument("<queryId>", "Query id (from the queries command)")
225
+ .argument("<queryText>", "New query text")
226
+ .action((queryId, queryText) =>
227
+ api.updateQuery(queryId, queryText).then(out).catch(fail)
228
+ );
229
+
230
+ program
231
+ .command("competitors")
232
+ .description("Tracked competitors used in share-of-voice and visibility comparisons")
233
+ .action(() => api.competitors().then(out).catch(fail));
234
+
235
+ program
236
+ .command("competitor-add")
237
+ .description("Track a competitor (max 10)")
238
+ .argument("<name>", "Competitor name")
239
+ .argument("<websiteUrl>", "Competitor website URL")
240
+ .action((name, websiteUrl) =>
241
+ api.addCompetitor(name, websiteUrl).then(out).catch(fail)
242
+ );
243
+
244
+ program
245
+ .command("competitor-remove")
246
+ .description("Stop tracking a competitor")
247
+ .argument("<competitorId>", "Competitor id (from the competitors command)")
248
+ .action((competitorId) =>
249
+ api.removeCompetitor(competitorId).then(out).catch(fail)
250
+ );
251
+
252
+ program
253
+ .command("topics")
254
+ .description("The pillar list (topic clusters): queries, ideas and planned articles group under these")
255
+ .action(() => api.topics().then(out).catch(fail));
256
+
257
+ program
258
+ .command("topics-set")
259
+ .description("Replace the full pillar list (create/dedupe/safe-delete reconcile)")
260
+ .argument("<topics...>", "Topic names, space-separated (quote multi-word topics)")
261
+ .action((topics) => api.setTopics(topics).then(out).catch(fail));
262
+
263
+ program
264
+ .command("delete-planned")
265
+ .description("Remove a planned title from the calendar; the title returns to Content Ideas")
266
+ .argument("<contentId>", "Planned contentPage id")
267
+ .action((contentId) => api.deletePlanned(contentId).then(out).catch(fail));
268
+
71
269
  program
72
270
  .command("capacity")
73
271
  .description("Remaining plan slots on the content calendar")
74
272
  .action(() => api.planningCapacity().then(out).catch(fail));
75
273
 
274
+ program
275
+ .command("team")
276
+ .description("Workspace members and pending invites (owner surface)")
277
+ .action(() => api.team().then(out).catch(fail));
278
+
279
+ program
280
+ .command("team-invite")
281
+ .description("Invite a member (dry-run by default; --confirm sends the email)")
282
+ .argument("<email>", "Invitee email")
283
+ .option(
284
+ "--perms <area=level,...>",
285
+ "Per-screen levels, e.g. content=write,analytics=read (unspecified screens default to none)"
286
+ )
287
+ .option("--confirm", "Actually send the invite")
288
+ .action((email, opts) => {
289
+ const screenPermissions = {};
290
+ for (const pair of (opts.perms ?? "").split(",").filter(Boolean)) {
291
+ const [area, level] = pair.split("=");
292
+ screenPermissions[area?.trim()] = level?.trim();
293
+ }
294
+ return api
295
+ .teamInvite({
296
+ email,
297
+ ...(Object.keys(screenPermissions).length ? { screenPermissions } : {}),
298
+ confirm: !!opts.confirm,
299
+ })
300
+ .then(out)
301
+ .catch(fail);
302
+ });
303
+
304
+ program
305
+ .command("team-revoke")
306
+ .description("Revoke a pending invite")
307
+ .argument("<invitationId>", "Invitation id from `team`")
308
+ .action((invitationId) => api.teamRevoke(invitationId).then(out).catch(fail));
309
+
310
+ program
311
+ .command("team-remove")
312
+ .description("Remove a member (requires --confirm)")
313
+ .argument("<userId>", "Member user id from `team`")
314
+ .option("--confirm", "Actually remove the member")
315
+ .action((userId, opts) =>
316
+ api
317
+ .teamRemove({ userId, confirm: !!opts.confirm })
318
+ .then(out)
319
+ .catch(fail)
320
+ );
321
+
322
+ program
323
+ .command("outreach-prospects")
324
+ .description("Link outreach pipeline with any found contact emails")
325
+ .action(() => api.outreachProspects().then(out).catch(fail));
326
+
327
+ program
328
+ .command("outreach-find-contact")
329
+ .description("Find an outreach email for a prospect (scrapes their site)")
330
+ .argument("<backlinkId>", "Prospect id from `outreach-prospects`")
331
+ .action((backlinkId) =>
332
+ api.outreachFindContact(backlinkId).then(out).catch(fail)
333
+ );
334
+
335
+ program
336
+ .command("outreach-draft-reply")
337
+ .description("AI-draft a reply to a prospect's inbound response (nothing sends)")
338
+ .argument("<backlinkId>", "Prospect id from `outreach-prospects`")
339
+ .action((backlinkId) =>
340
+ api.outreachDraftReply(backlinkId).then(out).catch(fail)
341
+ );
342
+
343
+ program
344
+ .command("outreach-queue")
345
+ .description("Queue an outreach email to send from the connected mailbox (dry-run by default)")
346
+ .argument("<backlinkId>", "Prospect id from `outreach-prospects`")
347
+ .option("--subject <subject>", "Override the drafted subject")
348
+ .option("--body <body>", "Override the drafted body")
349
+ .option("--confirm", "Actually queue the send")
350
+ .action((backlinkId, opts) =>
351
+ api
352
+ .outreachQueue({
353
+ backlinkId,
354
+ ...(opts.subject ? { subject: opts.subject } : {}),
355
+ ...(opts.body ? { body: opts.body } : {}),
356
+ confirm: !!opts.confirm,
357
+ })
358
+ .then(out)
359
+ .catch(fail)
360
+ );
361
+
76
362
  program
77
363
  .command("jobs")
78
364
  .description("Recent agent runs (async job status)")
@@ -131,5 +417,362 @@ export function runCli(argv) {
131
417
  api.publishContent(contentId, !!opts.confirm).then(out).catch(fail)
132
418
  );
133
419
 
420
+ program
421
+ .command("generate <contentId>")
422
+ .description("Write a planned article's body now, keeping its publish slot (past-due or ≤3 days ahead; dry run unless --confirm)")
423
+ .option("--confirm", "Actually start generation (spends LLM budget)")
424
+ .action((contentId, opts) =>
425
+ api.generateContent(contentId, !!opts.confirm).then(out).catch(fail)
426
+ );
427
+
428
+ program
429
+ .command("shopify-install-url <shop>")
430
+ .description("Mint a Shopify app install link for your store (open it in a browser; the destination is added to the workspace automatically)")
431
+ .action((shop) => api.shopifyInstallUrl(shop).then(out).catch(fail));
432
+
433
+ program
434
+ .command("settings")
435
+ .description("Show the article policy: per-article defaults + flexible scheduling")
436
+ .action(() => api.articleSettings().then(out).catch(fail));
437
+
438
+ const onOff = (val) => {
439
+ if (val === "on" || val === "true") return true;
440
+ if (val === "off" || val === "false") return false;
441
+ throw new Error(`Expected on|off, got "${val}"`);
442
+ };
443
+
444
+ program
445
+ .command("settings-set")
446
+ .description("Merge-patch the article policy (only flags you pass change)")
447
+ .option("--auto-publish <on|off>")
448
+ .option("--auto-generate <on|off>", "Off = fully manual: write articles only on demand (generate command / Write now)")
449
+ .option("--images <on|off>", "AI hero + section images")
450
+ .option("--title-in-hero <on|off>", "Write the post title into the hero image")
451
+ .option("--section-infographics <on|off>", "Informational panels in 2,000+ word articles")
452
+ .option("--related-reading <on|off>", "Related Reading link block at the end")
453
+ .option("--youtube <on|off>", "Embedded video suggestions")
454
+ .option("--emojis <on|off>")
455
+ .option("--internal-links <n>", "Internal links per article (1-20)")
456
+ .option("--external-links <n>", "Outbound authority links per article (0-15)")
457
+ .option("--instructions <text>", "Global editorial instructions")
458
+ .option("--flexible-schedule <on|off>", "1-5 articles/day + calendar drag-and-drop")
459
+ .option(
460
+ "--image-style <set>",
461
+ "Image style set for all generated images: classic-editorial | print-craft | storybook-painterly | modern-saas | dark-premium | bold-poster"
462
+ )
463
+ .action((opts) => {
464
+ try {
465
+ const s = {};
466
+ if (opts.autoPublish !== undefined) s.autoPublish = onOff(opts.autoPublish);
467
+ if (opts.autoGenerate !== undefined) s.autoGenerate = onOff(opts.autoGenerate);
468
+ if (opts.images !== undefined) s.includeInfographics = onOff(opts.images);
469
+ if (opts.titleInHero !== undefined) s.titleInHeroImage = onOff(opts.titleInHero);
470
+ if (opts.sectionInfographics !== undefined)
471
+ s.includeSectionInfographics = onOff(opts.sectionInfographics);
472
+ if (opts.relatedReading !== undefined)
473
+ s.includeRelatedReading = onOff(opts.relatedReading);
474
+ if (opts.youtube !== undefined) s.includeYouTube = onOff(opts.youtube);
475
+ if (opts.emojis !== undefined) s.useEmojis = onOff(opts.emojis);
476
+ if (opts.internalLinks !== undefined)
477
+ s.internalLinksPerArticle = Number(opts.internalLinks);
478
+ if (opts.externalLinks !== undefined)
479
+ s.externalLinksPerArticle = Number(opts.externalLinks);
480
+ if (opts.instructions !== undefined) s.globalInstructions = opts.instructions;
481
+ const body = {};
482
+ if (Object.keys(s).length > 0) body.articleSettings = s;
483
+ if (opts.flexibleSchedule !== undefined)
484
+ body.flexibleScheduling = onOff(opts.flexibleSchedule);
485
+ if (opts.imageStyle !== undefined) body.styleSet = opts.imageStyle;
486
+ if (Object.keys(body).length === 0)
487
+ throw new Error("Pass at least one flag to change (see --help)");
488
+ return api.updateArticleSettings(body).then(out).catch(fail);
489
+ } catch (err) {
490
+ fail(err);
491
+ }
492
+ });
493
+
494
+ program
495
+ .command("reschedule <contentId> <date>")
496
+ .description("Move a planned article to a day (YYYY-MM-DD, local time; needs the flexible schedule)")
497
+ .action((contentId, date) => {
498
+ const day = new Date(`${date}T00:00:00`);
499
+ if (Number.isNaN(day.getTime())) return fail(new Error("Date must be YYYY-MM-DD"));
500
+ return api.reschedule(contentId, day.getTime()).then(out).catch(fail);
501
+ });
502
+
503
+ program
504
+ .command("internal-links <contentId>")
505
+ .description("Inbound and outbound internal links recorded for an article (from the publish-time link graph)")
506
+ .action((contentId) => api.internalLinks(contentId).then(out).catch(fail));
507
+
508
+ program
509
+ .command("site-pages")
510
+ .description("List the site pages used for in-article links and Related Reading")
511
+ .action(() => api.sitePages().then(out).catch(fail));
512
+
513
+ program
514
+ .command("detect-links <url>")
515
+ .description("Scan a sitemap (nested indexes too) or a blog root page for site pages")
516
+ .option("--blog-root", "Treat the URL as a page to crawl instead of a sitemap")
517
+ .action((url, opts) =>
518
+ api
519
+ .detectSiteLinks(opts.blogRoot ? "blogroot" : "sitemap", url)
520
+ .then(out)
521
+ .catch(fail)
522
+ );
523
+
524
+ program
525
+ .command("add-pages <urls...>")
526
+ .description("Add site page URLs for internal linking (dedupes; titles auto-fill)")
527
+ .action((urls) => api.addSitePages(urls).then(out).catch(fail));
528
+
529
+ program
530
+ .command("repurpose [contentId]")
531
+ .description("Repurpose queue (published articles + draft chips), or full drafts for one article")
532
+ .action((contentId) =>
533
+ (contentId ? api.repurposeDrafts(contentId) : api.repurposeQueue())
534
+ .then(out)
535
+ .catch(fail)
536
+ );
537
+
538
+ program
539
+ .command("repurpose-generate <contentId>")
540
+ .description("Draft social posts for a published article (dry run unless --confirm; spends LLM budget)")
541
+ .option(
542
+ "--platforms <a,b>",
543
+ "Subset: linkedin,twitter,pinterest,instagram,facebook,threads,youtube,tiktok"
544
+ )
545
+ .option("--confirm", "Actually generate")
546
+ .action((contentId, opts) =>
547
+ api
548
+ .repurposeGenerate({
549
+ contentId,
550
+ platforms: opts.platforms ? list(opts.platforms) : undefined,
551
+ confirm: !!opts.confirm,
552
+ })
553
+ .then(out)
554
+ .catch(fail)
555
+ );
556
+
557
+ program
558
+ .command("repurpose-edit <draftId>")
559
+ .description("Edit a repurpose draft's text/title before pushing")
560
+ .option("--body <text>")
561
+ .option("--title <text>")
562
+ .action((draftId, opts) =>
563
+ api
564
+ .repurposeEditDraft({ draftId, body: opts.body, title: opts.title })
565
+ .then(out)
566
+ .catch(fail)
567
+ );
568
+
569
+ program
570
+ .command("repurpose-mark-posted <draftId>")
571
+ .description("Mark a draft as posted (you published it yourself)")
572
+ .action((draftId) => api.repurposeMarkPosted(draftId).then(out).catch(fail));
573
+
574
+ program
575
+ .command("repurpose-channels")
576
+ .description("Connected Postiz channels (ids needed for repurpose-push)")
577
+ .action(() => api.repurposeChannels().then(out).catch(fail));
578
+
579
+ program
580
+ .command("repurpose-push <draftId>")
581
+ .description("Send a draft to Postiz (dry run unless --confirm)")
582
+ .option("--channels <id1,id2>", "Postiz channel ids (see repurpose-channels)")
583
+ .option("--when <now|schedule|draft>", "Postiz mode", "now")
584
+ .option("--date <iso>", "ISO time for --when schedule")
585
+ .option("--confirm", "Actually push (publicly visible)")
586
+ .action((draftId, opts) =>
587
+ api
588
+ .repurposePush({
589
+ draftId,
590
+ integrationIds: opts.channels ? list(opts.channels) : undefined,
591
+ scheduleType: opts.when,
592
+ date: opts.date,
593
+ confirm: !!opts.confirm,
594
+ })
595
+ .then(out)
596
+ .catch(fail)
597
+ );
598
+
599
+ const parseJson = (raw, label) => {
600
+ try {
601
+ return JSON.parse(raw.startsWith("@") ? readFileSync(raw.slice(1), "utf8") : raw);
602
+ } catch {
603
+ fail(new Error(`--json for ${label} must be valid JSON (or @file.json)`));
604
+ }
605
+ };
606
+
607
+ program
608
+ .command("ideas")
609
+ .description("Scored content-idea backlog: uncovered queries, citation gaps, quick wins")
610
+ .action(() => api.contentIdeas().then(out).catch(fail));
611
+
612
+ program
613
+ .command("plan-idea <queryText>")
614
+ .description("Put an idea on the content calendar (uses its stored title, or generates one)")
615
+ .option("--title <title>", "Exact headline to plan")
616
+ .action((queryText, opts) =>
617
+ api.planIdea({ queryText, title: opts.title }).then(out).catch(fail)
618
+ );
619
+
620
+ program
621
+ .command("archive <contentId>")
622
+ .description("Archive an article (removes it from the working set)")
623
+ .action((contentId) => api.archiveContent(contentId).then(out).catch(fail));
624
+
625
+ program
626
+ .command("engagement")
627
+ .description("Per-page views + citations for the last 30 days, with view sparklines")
628
+ .action(() => api.pageEngagement().then(out).catch(fail));
629
+
630
+ program
631
+ .command("report")
632
+ .description("Executive summary: last 30 days vs the 30 before (citations, views, crawls, publishing)")
633
+ .action(() => api.reportSummary().then(out).catch(fail));
634
+
635
+ program
636
+ .command("wins")
637
+ .description("Biggest wins of the last 30 days: top cited page, cite-rate jump, ranking climb, best backlink")
638
+ .action(() => api.reportWins().then(out).catch(fail));
639
+
640
+ program
641
+ .command("agent-activity")
642
+ .description("Recent runs per agent lane (brand control, radar, forge, deploy, ...)")
643
+ .option("--per-agent <n>", "Runs per lane, up to 50", "15")
644
+ .option("--since-days <n>", "Window in days", "30")
645
+ .action((opts) =>
646
+ api
647
+ .agentActivity({ perAgent: Number(opts.perAgent), sinceDays: Number(opts.sinceDays) })
648
+ .then(out)
649
+ .catch(fail)
650
+ );
651
+
652
+ program
653
+ .command("backlinks")
654
+ .description("The backlink table, newest first")
655
+ .option("--status <status>", "Filter: discovered, verified, lost, identified, contacted, replied, link_placed, rejected")
656
+ .action((opts) => api.backlinks(opts.status).then(out).catch(fail));
657
+
658
+ program
659
+ .command("backlink-stats")
660
+ .description("Backlink totals + the outreach pipeline counts")
661
+ .action(() => api.backlinkStats().then(out).catch(fail));
662
+
663
+ program
664
+ .command("outreach-status <backlinkId> <status>")
665
+ .description("Move an outreach prospect through the pipeline (identified|contacted|replied|link_placed|rejected)")
666
+ .action((backlinkId, status) =>
667
+ api.outreachStatus(backlinkId, status).then(out).catch(fail)
668
+ );
669
+
670
+ program
671
+ .command("network")
672
+ .description("Link Network credit stats, membership state, and hosted/received placements")
673
+ .action(() => api.linkNetwork().then(out).catch(fail));
674
+
675
+ program
676
+ .command("network-opt-in <on|off>")
677
+ .description("Join or leave the Link Network (dry run unless --confirm; leaving retires live links)")
678
+ .option("--confirm", "Actually change membership")
679
+ .action((state, opts) =>
680
+ api.linkNetworkOptIn(state === "on", !!opts.confirm).then(out).catch(fail)
681
+ );
682
+
683
+ program
684
+ .command("network-remove-placement <placementId>")
685
+ .description("Retire one network placement (dry run unless --confirm; visible on the partner site)")
686
+ .option("--confirm", "Actually remove it")
687
+ .action((placementId, opts) =>
688
+ api.linkNetworkRemovePlacement(placementId, !!opts.confirm).then(out).catch(fail)
689
+ );
690
+
691
+ program
692
+ .command("social")
693
+ .description("Social thread prospects (Reddit/X) with rules context and intent sort")
694
+ .option("--platform <reddit|twitter>")
695
+ .option("--status <status>")
696
+ .option("--age <ranked|new>")
697
+ .action((opts) =>
698
+ api
699
+ .socialThreads({ platform: opts.platform, status: opts.status, age: opts.age })
700
+ .then(out)
701
+ .catch(fail)
702
+ );
703
+
704
+ program
705
+ .command("social-stats")
706
+ .description("Social thread pipeline counts")
707
+ .action(() => api.socialStats().then(out).catch(fail));
708
+
709
+ program
710
+ .command("social-status <threadId> <status>")
711
+ .description("Move a social thread through the pipeline")
712
+ .action((threadId, status) =>
713
+ api.socialStatus(threadId, status).then(out).catch(fail)
714
+ );
715
+
716
+ program
717
+ .command("social-draft-reply <threadId>")
718
+ .description("AI-draft a reply for a thread (nothing posts; the draft lands for review)")
719
+ .option("--mention <none|natural|founderOpen>", "Brand-mention mode")
720
+ .action((threadId, opts) =>
721
+ api.socialDraftReply(threadId, opts.mention).then(out).catch(fail)
722
+ );
723
+
724
+ program
725
+ .command("support <subject>")
726
+ .description("Send a message or bug report to RankControl support (emails the team, capped 3/min)")
727
+ .requiredOption("--message <text>", "What happened — include errors, ids, and steps")
728
+ .option("--page <url>", "Related dashboard or API URL")
729
+ .action((subject, opts) =>
730
+ api.support({ subject, message: opts.message, pageUrl: opts.page }).then(out).catch(fail)
731
+ );
732
+
733
+ program
734
+ .command("brand")
735
+ .description("Brand profile, products, and buyer profiles (ICPs) in one read")
736
+ .action(() => api.brand().then(out).catch(fail));
737
+
738
+ program
739
+ .command("brand-set")
740
+ .description("Update brand identity: name, industry, product description, authors, style reference URLs")
741
+ .option("--name <name>", "Company/brand name")
742
+ .option("--industry <industry>", "Industry label")
743
+ .option("--description <text>", "Product description")
744
+ .option("--json <json>", "Full patch as JSON (or @file.json): {name?, industry?, productDescription?, authors?, styleReferenceUrls?}; authors/styleReferenceUrls replace the stored list")
745
+ .action((opts) => {
746
+ const patch = opts.json ? parseJson(opts.json, "brand-set") : {};
747
+ if (opts.name) patch.name = opts.name;
748
+ if (opts.industry) patch.industry = opts.industry;
749
+ if (opts.description) patch.productDescription = opts.description;
750
+ api.brandIdentitySet(patch).then(out).catch(fail);
751
+ });
752
+
753
+ program
754
+ .command("brand-profile-set")
755
+ .description("Patch brand voice/style fields. --json '{\"tone\":\"...\"}' or --json @file.json")
756
+ .requiredOption("--json <json>", "Fields: tone, primaryColor, secondaryColor, fontFamily, metaTitle, metaDescription, metaKeywords, defaultLocale, flexibleScheduling")
757
+ .action((opts) =>
758
+ api.brandProfileSet(parseJson(opts.json, "brand-profile-set")).then(out).catch(fail)
759
+ );
760
+
761
+ program
762
+ .command("brand-product")
763
+ .description("Create, update, or delete a product. Create: name+description+category; update: productId + fields; delete: productId + del:true")
764
+ .requiredOption("--json <json>", "Product JSON (or @file.json)")
765
+ .action((opts) =>
766
+ api.brandProductWrite(parseJson(opts.json, "brand-product")).then(out).catch(fail)
767
+ );
768
+
769
+ program
770
+ .command("brand-icp")
771
+ .description("Create, update, or delete a buyer profile. Create: title+industry+demographics; update: profileId + fields; delete: profileId + del:true")
772
+ .requiredOption("--json <json>", "Buyer profile JSON (or @file.json)")
773
+ .action((opts) =>
774
+ api.brandIcpWrite(parseJson(opts.json, "brand-icp")).then(out).catch(fail)
775
+ );
776
+
134
777
  program.parseAsync(argv);
135
778
  }