rankcontrol 0.1.0 → 0.6.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 CHANGED
@@ -9,25 +9,58 @@ Every command calls the same authed, rate-limited, org-scoped API the RankContro
9
9
 
10
10
  ## Auth
11
11
 
12
- Create an API key in **RankControl Settings API**, then:
12
+ The easy way approve in your browser (you must be logged in to RankControl):
13
+
14
+ ```bash
15
+ npx rankcontrol login
16
+ ```
17
+
18
+ That opens your dashboard, you check the confirmation code matches, pick the permissions to grant, and the CLI stores a scoped key in `~/.rankcontrol/config.json`. `npx rankcontrol logout` removes it (revoke the key itself in Settings → API).
19
+
20
+ The headless way (CI, servers, MCP `env` blocks) — create a key in **RankControl → Settings → API**, then:
13
21
 
14
22
  ```bash
15
23
  export RANKCONTROL_API_KEY=rctrl_pk_...
16
24
  ```
17
25
 
18
- Scopes are set per key. Read-only keys work for all `get_*`/`list_*` tools.
26
+ The env var wins over the stored config when both exist. Scopes are set per key; read-only keys work for all `get_*`/`list_*` tools.
19
27
 
20
28
  ## CLI
21
29
 
22
30
  ```bash
23
31
  npx rankcontrol funnel # AI crawls / AI visits / AI leads (30d)
32
+ npx rankcontrol score # composite visibility score + subscores (AI/Google/Bing)
33
+ npx rankcontrol leads --limit 100 # captured leads with AI source attribution
34
+ npx rankcontrol traffic --days 30 # page views, visitors, bounce rate, time on page
24
35
  npx rankcontrol visibility --days 60 # daily AI visibility score trend
36
+ npx rankcontrol sov --days 30 # share of voice vs competitors
37
+ npx rankcontrol sources --days 30 # cited domains typed brand/competitive/ugc/editorial
38
+ npx rankcontrol sentiment --days 30 # how AI answers frame your brand
39
+ npx rankcontrol optimizer --limit 25 # pages ranked by citability, worst first
25
40
  npx rankcontrol citations --model chatgpt
41
+ npx rankcontrol queries # tracked queries checked weekly across AI engines
26
42
  npx rankcontrol capacity # remaining content-plan slots
27
43
  npx rankcontrol plan-content --topics "bus travel" --max-difficulty 40
28
44
  npx rankcontrol commit-titles --file titles.json
29
45
  npx rankcontrol publish <contentId> # dry run
30
46
  npx rankcontrol publish <contentId> --confirm # actually publish
47
+ npx rankcontrol generate <contentId> # dry run
48
+ npx rankcontrol generate <contentId> --confirm # write a planned article now (slot unchanged)
49
+ npx rankcontrol settings # article policy (auto-write, auto-publish, images, ...)
50
+ npx rankcontrol settings-set --auto-generate off --auto-publish off
51
+ npx rankcontrol reschedule <contentId> 2026-08-04
52
+ npx rankcontrol internal-links <contentId> # who links here / links out
53
+ npx rankcontrol site-pages # pages used for internal links
54
+ npx rankcontrol detect-links https://example.com/sitemap.xml
55
+ npx rankcontrol add-pages https://example.com/pricing
56
+ npx rankcontrol repurpose # published articles + social draft chips
57
+ npx rankcontrol repurpose <contentId> # full drafts for one article
58
+ npx rankcontrol repurpose-generate <contentId> --platforms twitter --confirm
59
+ npx rankcontrol repurpose-edit <draftId> --title "New hook"
60
+ npx rankcontrol repurpose-channels # Postiz channel ids (for repurpose-push)
61
+ npx rankcontrol repurpose-push <draftId> --channels <id> # dry run
62
+ npx rankcontrol repurpose-push <draftId> --channels <id> --confirm
63
+ npx rankcontrol repurpose-mark-posted <draftId> # posted it yourself
31
64
  npx rankcontrol jobs # async job status (agent runs)
32
65
  ```
33
66
 
@@ -58,14 +91,34 @@ Or in `.mcp.json` / Cursor `mcp.json`:
58
91
  | Tool | What it does |
59
92
  |---|---|
60
93
  | `get_overview_funnel` | AI crawls / AI visits / AI leads, last 30 days |
94
+ | `list_leads` | Captured leads with AI model / query / page attribution |
95
+ | `get_traffic_overview` | Page views, visitors, sessions, bounce rate, time on page |
61
96
  | `get_visibility_trend` | Daily visibility score from stored weekly citation checks |
62
97
  | `list_citations` | Recent citation checks per AI model |
63
- | `get_visibility_score` | Current overall AI visibility score |
98
+ | `list_tracked_queries` | The queries checked weekly across the AI engines |
99
+ | `get_visibility_score` | Composite score (50% AI + 30% Google + 20% Bing) with subscores |
100
+ | `get_share_of_voice` | Your citation rate vs competitor mention rates + your share |
101
+ | `get_citation_sources` | Cited domains typed brand / competitive / UGC / editorial |
102
+ | `get_citation_sentiment` | Positive / neutral / negative framing with recent receipts |
103
+ | `get_citability_report` | Published pages by citability score, worst first, with fixes |
64
104
  | `get_planning_capacity` | Remaining calendar slots (check before planning) |
65
105
  | `plan_content` | Generate candidate titles (capacity-gated, nothing scheduled) |
66
106
  | `commit_planned_titles` | Approve reviewed titles onto the calendar |
67
107
  | `publish_content` | Dry run by default; `confirm: true` publishes |
108
+ | `generate_article` | Write a planned article's body now, slot unchanged; dry run by default |
109
+ | `get_article_settings` / `update_article_settings` | Read / merge-patch the article policy (auto-write, auto-publish, images, links, ...) |
110
+ | `reschedule_article` | Move a planned article to another day (flexible schedule) |
111
+ | `get_internal_links` | Who links to this article internally, and its outbound count |
112
+ | `list_site_pages` / `add_site_pages` | Pages used for in-article internal links |
113
+ | `detect_site_links` | Scan a sitemap or blog root for site pages |
114
+ | `list_repurpose_queue` | Published articles + social draft chips per platform |
115
+ | `get_repurpose_drafts` | Full social drafts for one article (8 platforms) |
116
+ | `generate_repurpose_drafts` | Draft social posts for an article; dry run by default |
117
+ | `update_repurpose_draft` | Edit a draft's body/title before pushing |
118
+ | `mark_repurpose_posted` | Record a draft as posted manually |
119
+ | `list_postiz_channels` | Connected Postiz channels (ids for pushing) |
120
+ | `push_repurpose_draft` | Send a draft to Postiz; dry run by default |
68
121
  | `list_content` | Content pages with status |
69
122
  | `list_jobs` | Async job status (agent runs) |
70
123
 
71
- The plan→commit split is intentional: `plan_content` proposes, a human (or a supervising agent) reviews, `commit_planned_titles` approves. `publish_content` is dry-run-first for the same reason.
124
+ The plan→commit split is intentional: `plan_content` proposes, a human (or a supervising agent) reviews, `commit_planned_titles` approves. `publish_content` and `generate_article` are dry-run-first for the same reason.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rankcontrol",
3
- "version": "0.1.0",
3
+ "version": "0.6.0",
4
4
  "description": "RankControl CLI + MCP server: drive your SEO/AI-visibility workspace from the terminal or any AI agent",
5
5
  "license": "MIT",
6
6
  "homepage": "https://rctrl.com",
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
- "RankControl from the terminal. Auth: export RANKCONTROL_API_KEY=rctrl_pk_... (Settings → API)"
22
+ "RankControl from the terminal. Auth: `rankcontrol login` (browser approval) or export RANKCONTROL_API_KEY=rctrl_pk_..."
20
23
  )
21
- .version("0.1.0");
24
+ .version(pkg.version);
22
25
 
23
26
  program
24
27
  .command("mcp")
@@ -28,17 +31,75 @@ export function runCli(argv) {
28
31
  await startMcpServer().catch(fail);
29
32
  });
30
33
 
34
+ program
35
+ .command("login")
36
+ .description("Authenticate via your browser (approves a scoped API key into ~/.rankcontrol)")
37
+ .option("--scopes <a,b,c>", "Scopes to request (default: reads + write:content + write:publish)")
38
+ .action(async (opts) => {
39
+ const { login } = await import("./login.mjs");
40
+ await login({ scopes: opts.scopes ? list(opts.scopes) : undefined }).catch(fail);
41
+ });
42
+
43
+ program
44
+ .command("logout")
45
+ .description("Remove locally stored credentials")
46
+ .action(async () => {
47
+ const { logout } = await import("./login.mjs");
48
+ logout();
49
+ });
50
+
31
51
  program
32
52
  .command("funnel")
33
53
  .description("AI pipeline last 30 days: crawls, AI visits, AI leads")
34
54
  .action(() => api.overviewFunnel().then(out).catch(fail));
35
55
 
56
+ program
57
+ .command("leads")
58
+ .description("Captured leads with source attribution (AI model, query, page)")
59
+ .option("--limit <n>", "Max rows, up to 500", "100")
60
+ .action((opts) => api.leads(Number(opts.limit)).then(out).catch(fail));
61
+
62
+ program
63
+ .command("traffic")
64
+ .description("Traffic overview: page views, visitors, sessions, bounce rate, time on page")
65
+ .option("--days <n>", "7, 30 or 90", "30")
66
+ .action((opts) => api.trafficOverview(Number(opts.days)).then(out).catch(fail));
67
+
68
+ program
69
+ .command("score")
70
+ .description("Composite visibility score: 50% AI citation rate + 30% Google + 20% Bing rank share, with subscores")
71
+ .action(() => api.visibilityScore().then(out).catch(fail));
72
+
36
73
  program
37
74
  .command("visibility")
38
75
  .description("Daily AI visibility score trend from the stored weekly citation checks")
39
76
  .option("--days <n>", "30, 60 or 90", "30")
40
77
  .action((opts) => api.visibilityTrend(Number(opts.days)).then(out).catch(fail));
41
78
 
79
+ program
80
+ .command("sov")
81
+ .description("Share of voice: your citation rate vs top competitors, plus your share of all brand appearances")
82
+ .option("--days <n>", "Window in days, 7-90", "30")
83
+ .action((opts) => api.shareOfVoice(Number(opts.days)).then(out).catch(fail));
84
+
85
+ program
86
+ .command("sources")
87
+ .description("Domains AI answers cite for your tracked queries, typed brand/competitive/ugc/editorial")
88
+ .option("--days <n>", "Window in days, 7-90", "30")
89
+ .action((opts) => api.citationSources(Number(opts.days)).then(out).catch(fail));
90
+
91
+ program
92
+ .command("sentiment")
93
+ .description("How AI answers frame your brand when cited: positive/neutral/negative with recent receipts")
94
+ .option("--days <n>", "Window in days, 7-90", "30")
95
+ .action((opts) => api.citationSentiment(Number(opts.days)).then(out).catch(fail));
96
+
97
+ program
98
+ .command("optimizer")
99
+ .description("Published pages ranked by citability score, worst first, with their open fixes")
100
+ .option("--limit <n>", "Max rows", "25")
101
+ .action((opts) => api.optimizer(Number(opts.limit)).then(out).catch(fail));
102
+
42
103
  program
43
104
  .command("citations")
44
105
  .description("Recent AI citation checks")
@@ -51,11 +112,115 @@ export function runCli(argv) {
51
112
  .catch(fail)
52
113
  );
53
114
 
115
+ program
116
+ .command("queries")
117
+ .description("List the tracked queries checked weekly across the AI engines")
118
+ .action(() => api.trackedQueries().then(out).catch(fail));
119
+
120
+ program
121
+ .command("crawler-access")
122
+ .description("Daily AI-crawler reachability probe: is the site's edge or robots.txt blocking GPTBot/ClaudeBot")
123
+ .action(() => api.crawlerAccess().then(out).catch(fail));
124
+
125
+ program
126
+ .command("query-add")
127
+ .description("Add a query to the tracking pool (tracks immediately when a plan slot is free)")
128
+ .argument("<queryText>", "The search/AI query to track")
129
+ .action((queryText) => api.addQuery(queryText).then(out).catch(fail));
130
+
54
131
  program
55
132
  .command("capacity")
56
133
  .description("Remaining plan slots on the content calendar")
57
134
  .action(() => api.planningCapacity().then(out).catch(fail));
58
135
 
136
+ program
137
+ .command("team")
138
+ .description("Workspace members and pending invites (owner surface)")
139
+ .action(() => api.team().then(out).catch(fail));
140
+
141
+ program
142
+ .command("team-invite")
143
+ .description("Invite a member (dry-run by default; --confirm sends the email)")
144
+ .argument("<email>", "Invitee email")
145
+ .option(
146
+ "--perms <area=level,...>",
147
+ "Per-screen levels, e.g. content=write,analytics=read (unspecified screens default to none)"
148
+ )
149
+ .option("--confirm", "Actually send the invite")
150
+ .action((email, opts) => {
151
+ const screenPermissions = {};
152
+ for (const pair of (opts.perms ?? "").split(",").filter(Boolean)) {
153
+ const [area, level] = pair.split("=");
154
+ screenPermissions[area?.trim()] = level?.trim();
155
+ }
156
+ return api
157
+ .teamInvite({
158
+ email,
159
+ ...(Object.keys(screenPermissions).length ? { screenPermissions } : {}),
160
+ confirm: !!opts.confirm,
161
+ })
162
+ .then(out)
163
+ .catch(fail);
164
+ });
165
+
166
+ program
167
+ .command("team-revoke")
168
+ .description("Revoke a pending invite")
169
+ .argument("<invitationId>", "Invitation id from `team`")
170
+ .action((invitationId) => api.teamRevoke(invitationId).then(out).catch(fail));
171
+
172
+ program
173
+ .command("team-remove")
174
+ .description("Remove a member (requires --confirm)")
175
+ .argument("<userId>", "Member user id from `team`")
176
+ .option("--confirm", "Actually remove the member")
177
+ .action((userId, opts) =>
178
+ api
179
+ .teamRemove({ userId, confirm: !!opts.confirm })
180
+ .then(out)
181
+ .catch(fail)
182
+ );
183
+
184
+ program
185
+ .command("outreach-prospects")
186
+ .description("Link outreach pipeline with any found contact emails")
187
+ .action(() => api.outreachProspects().then(out).catch(fail));
188
+
189
+ program
190
+ .command("outreach-find-contact")
191
+ .description("Find an outreach email for a prospect (scrapes their site)")
192
+ .argument("<backlinkId>", "Prospect id from `outreach-prospects`")
193
+ .action((backlinkId) =>
194
+ api.outreachFindContact(backlinkId).then(out).catch(fail)
195
+ );
196
+
197
+ program
198
+ .command("outreach-draft-reply")
199
+ .description("AI-draft a reply to a prospect's inbound response (nothing sends)")
200
+ .argument("<backlinkId>", "Prospect id from `outreach-prospects`")
201
+ .action((backlinkId) =>
202
+ api.outreachDraftReply(backlinkId).then(out).catch(fail)
203
+ );
204
+
205
+ program
206
+ .command("outreach-queue")
207
+ .description("Queue an outreach email to send from the connected mailbox (dry-run by default)")
208
+ .argument("<backlinkId>", "Prospect id from `outreach-prospects`")
209
+ .option("--subject <subject>", "Override the drafted subject")
210
+ .option("--body <body>", "Override the drafted body")
211
+ .option("--confirm", "Actually queue the send")
212
+ .action((backlinkId, opts) =>
213
+ api
214
+ .outreachQueue({
215
+ backlinkId,
216
+ ...(opts.subject ? { subject: opts.subject } : {}),
217
+ ...(opts.body ? { body: opts.body } : {}),
218
+ confirm: !!opts.confirm,
219
+ })
220
+ .then(out)
221
+ .catch(fail)
222
+ );
223
+
59
224
  program
60
225
  .command("jobs")
61
226
  .description("Recent agent runs (async job status)")
@@ -114,5 +279,342 @@ export function runCli(argv) {
114
279
  api.publishContent(contentId, !!opts.confirm).then(out).catch(fail)
115
280
  );
116
281
 
282
+ program
283
+ .command("generate <contentId>")
284
+ .description("Write a planned article's body now, keeping its publish slot (past-due or ≤3 days ahead; dry run unless --confirm)")
285
+ .option("--confirm", "Actually start generation (spends LLM budget)")
286
+ .action((contentId, opts) =>
287
+ api.generateContent(contentId, !!opts.confirm).then(out).catch(fail)
288
+ );
289
+
290
+ program
291
+ .command("settings")
292
+ .description("Show the article policy: per-article defaults + flexible scheduling")
293
+ .action(() => api.articleSettings().then(out).catch(fail));
294
+
295
+ const onOff = (val) => {
296
+ if (val === "on" || val === "true") return true;
297
+ if (val === "off" || val === "false") return false;
298
+ throw new Error(`Expected on|off, got "${val}"`);
299
+ };
300
+
301
+ program
302
+ .command("settings-set")
303
+ .description("Merge-patch the article policy (only flags you pass change)")
304
+ .option("--auto-publish <on|off>")
305
+ .option("--auto-generate <on|off>", "Off = fully manual: write articles only on demand (generate command / Write now)")
306
+ .option("--images <on|off>", "AI hero + section images")
307
+ .option("--title-in-hero <on|off>", "Write the post title into the hero image")
308
+ .option("--section-infographics <on|off>", "Informational panels in 2,000+ word articles")
309
+ .option("--related-reading <on|off>", "Related Reading link block at the end")
310
+ .option("--youtube <on|off>", "Embedded video suggestions")
311
+ .option("--emojis <on|off>")
312
+ .option("--internal-links <n>", "Internal links per article (1-20)")
313
+ .option("--external-links <n>", "Outbound authority links per article (0-15)")
314
+ .option("--instructions <text>", "Global editorial instructions")
315
+ .option("--flexible-schedule <on|off>", "1-5 articles/day + calendar drag-and-drop")
316
+ .option(
317
+ "--image-style <set>",
318
+ "Image style set for all generated images: classic-editorial | print-craft | storybook-painterly | modern-saas | dark-premium | bold-poster"
319
+ )
320
+ .action((opts) => {
321
+ try {
322
+ const s = {};
323
+ if (opts.autoPublish !== undefined) s.autoPublish = onOff(opts.autoPublish);
324
+ if (opts.autoGenerate !== undefined) s.autoGenerate = onOff(opts.autoGenerate);
325
+ if (opts.images !== undefined) s.includeInfographics = onOff(opts.images);
326
+ if (opts.titleInHero !== undefined) s.titleInHeroImage = onOff(opts.titleInHero);
327
+ if (opts.sectionInfographics !== undefined)
328
+ s.includeSectionInfographics = onOff(opts.sectionInfographics);
329
+ if (opts.relatedReading !== undefined)
330
+ s.includeRelatedReading = onOff(opts.relatedReading);
331
+ if (opts.youtube !== undefined) s.includeYouTube = onOff(opts.youtube);
332
+ if (opts.emojis !== undefined) s.useEmojis = onOff(opts.emojis);
333
+ if (opts.internalLinks !== undefined)
334
+ s.internalLinksPerArticle = Number(opts.internalLinks);
335
+ if (opts.externalLinks !== undefined)
336
+ s.externalLinksPerArticle = Number(opts.externalLinks);
337
+ if (opts.instructions !== undefined) s.globalInstructions = opts.instructions;
338
+ const body = {};
339
+ if (Object.keys(s).length > 0) body.articleSettings = s;
340
+ if (opts.flexibleSchedule !== undefined)
341
+ body.flexibleScheduling = onOff(opts.flexibleSchedule);
342
+ if (opts.imageStyle !== undefined) body.styleSet = opts.imageStyle;
343
+ if (Object.keys(body).length === 0)
344
+ throw new Error("Pass at least one flag to change (see --help)");
345
+ return api.updateArticleSettings(body).then(out).catch(fail);
346
+ } catch (err) {
347
+ fail(err);
348
+ }
349
+ });
350
+
351
+ program
352
+ .command("reschedule <contentId> <date>")
353
+ .description("Move a planned article to a day (YYYY-MM-DD, local time; needs the flexible schedule)")
354
+ .action((contentId, date) => {
355
+ const day = new Date(`${date}T00:00:00`);
356
+ if (Number.isNaN(day.getTime())) return fail(new Error("Date must be YYYY-MM-DD"));
357
+ return api.reschedule(contentId, day.getTime()).then(out).catch(fail);
358
+ });
359
+
360
+ program
361
+ .command("internal-links <contentId>")
362
+ .description("Inbound and outbound internal links recorded for an article (from the publish-time link graph)")
363
+ .action((contentId) => api.internalLinks(contentId).then(out).catch(fail));
364
+
365
+ program
366
+ .command("site-pages")
367
+ .description("List the site pages used for in-article links and Related Reading")
368
+ .action(() => api.sitePages().then(out).catch(fail));
369
+
370
+ program
371
+ .command("detect-links <url>")
372
+ .description("Scan a sitemap (nested indexes too) or a blog root page for site pages")
373
+ .option("--blog-root", "Treat the URL as a page to crawl instead of a sitemap")
374
+ .action((url, opts) =>
375
+ api
376
+ .detectSiteLinks(opts.blogRoot ? "blogroot" : "sitemap", url)
377
+ .then(out)
378
+ .catch(fail)
379
+ );
380
+
381
+ program
382
+ .command("add-pages <urls...>")
383
+ .description("Add site page URLs for internal linking (dedupes; titles auto-fill)")
384
+ .action((urls) => api.addSitePages(urls).then(out).catch(fail));
385
+
386
+ program
387
+ .command("repurpose [contentId]")
388
+ .description("Repurpose queue (published articles + draft chips), or full drafts for one article")
389
+ .action((contentId) =>
390
+ (contentId ? api.repurposeDrafts(contentId) : api.repurposeQueue())
391
+ .then(out)
392
+ .catch(fail)
393
+ );
394
+
395
+ program
396
+ .command("repurpose-generate <contentId>")
397
+ .description("Draft social posts for a published article (dry run unless --confirm; spends LLM budget)")
398
+ .option(
399
+ "--platforms <a,b>",
400
+ "Subset: linkedin,twitter,pinterest,instagram,facebook,threads,youtube,tiktok"
401
+ )
402
+ .option("--confirm", "Actually generate")
403
+ .action((contentId, opts) =>
404
+ api
405
+ .repurposeGenerate({
406
+ contentId,
407
+ platforms: opts.platforms ? list(opts.platforms) : undefined,
408
+ confirm: !!opts.confirm,
409
+ })
410
+ .then(out)
411
+ .catch(fail)
412
+ );
413
+
414
+ program
415
+ .command("repurpose-edit <draftId>")
416
+ .description("Edit a repurpose draft's text/title before pushing")
417
+ .option("--body <text>")
418
+ .option("--title <text>")
419
+ .action((draftId, opts) =>
420
+ api
421
+ .repurposeEditDraft({ draftId, body: opts.body, title: opts.title })
422
+ .then(out)
423
+ .catch(fail)
424
+ );
425
+
426
+ program
427
+ .command("repurpose-mark-posted <draftId>")
428
+ .description("Mark a draft as posted (you published it yourself)")
429
+ .action((draftId) => api.repurposeMarkPosted(draftId).then(out).catch(fail));
430
+
431
+ program
432
+ .command("repurpose-channels")
433
+ .description("Connected Postiz channels (ids needed for repurpose-push)")
434
+ .action(() => api.repurposeChannels().then(out).catch(fail));
435
+
436
+ program
437
+ .command("repurpose-push <draftId>")
438
+ .description("Send a draft to Postiz (dry run unless --confirm)")
439
+ .option("--channels <id1,id2>", "Postiz channel ids (see repurpose-channels)")
440
+ .option("--when <now|schedule|draft>", "Postiz mode", "now")
441
+ .option("--date <iso>", "ISO time for --when schedule")
442
+ .option("--confirm", "Actually push (publicly visible)")
443
+ .action((draftId, opts) =>
444
+ api
445
+ .repurposePush({
446
+ draftId,
447
+ integrationIds: opts.channels ? list(opts.channels) : undefined,
448
+ scheduleType: opts.when,
449
+ date: opts.date,
450
+ confirm: !!opts.confirm,
451
+ })
452
+ .then(out)
453
+ .catch(fail)
454
+ );
455
+
456
+ const parseJson = (raw, label) => {
457
+ try {
458
+ return JSON.parse(raw.startsWith("@") ? readFileSync(raw.slice(1), "utf8") : raw);
459
+ } catch {
460
+ fail(new Error(`--json for ${label} must be valid JSON (or @file.json)`));
461
+ }
462
+ };
463
+
464
+ program
465
+ .command("ideas")
466
+ .description("Scored content-idea backlog: uncovered queries, citation gaps, quick wins")
467
+ .action(() => api.contentIdeas().then(out).catch(fail));
468
+
469
+ program
470
+ .command("plan-idea <queryText>")
471
+ .description("Put an idea on the content calendar (uses its stored title, or generates one)")
472
+ .option("--title <title>", "Exact headline to plan")
473
+ .action((queryText, opts) =>
474
+ api.planIdea({ queryText, title: opts.title }).then(out).catch(fail)
475
+ );
476
+
477
+ program
478
+ .command("archive <contentId>")
479
+ .description("Archive an article (removes it from the working set)")
480
+ .action((contentId) => api.archiveContent(contentId).then(out).catch(fail));
481
+
482
+ program
483
+ .command("engagement")
484
+ .description("Per-page views + citations for the last 30 days, with view sparklines")
485
+ .action(() => api.pageEngagement().then(out).catch(fail));
486
+
487
+ program
488
+ .command("report")
489
+ .description("Executive summary: last 30 days vs the 30 before (citations, views, crawls, publishing)")
490
+ .action(() => api.reportSummary().then(out).catch(fail));
491
+
492
+ program
493
+ .command("wins")
494
+ .description("Biggest wins of the last 30 days: top cited page, cite-rate jump, ranking climb, best backlink")
495
+ .action(() => api.reportWins().then(out).catch(fail));
496
+
497
+ program
498
+ .command("agent-activity")
499
+ .description("Recent runs per agent lane (brand control, radar, forge, deploy, ...)")
500
+ .option("--per-agent <n>", "Runs per lane, up to 50", "15")
501
+ .option("--since-days <n>", "Window in days", "30")
502
+ .action((opts) =>
503
+ api
504
+ .agentActivity({ perAgent: Number(opts.perAgent), sinceDays: Number(opts.sinceDays) })
505
+ .then(out)
506
+ .catch(fail)
507
+ );
508
+
509
+ program
510
+ .command("backlinks")
511
+ .description("The backlink table, newest first")
512
+ .option("--status <status>", "Filter: discovered, verified, lost, identified, contacted, replied, link_placed, rejected")
513
+ .action((opts) => api.backlinks(opts.status).then(out).catch(fail));
514
+
515
+ program
516
+ .command("backlink-stats")
517
+ .description("Backlink totals + the outreach pipeline counts")
518
+ .action(() => api.backlinkStats().then(out).catch(fail));
519
+
520
+ program
521
+ .command("outreach-status <backlinkId> <status>")
522
+ .description("Move an outreach prospect through the pipeline (identified|contacted|replied|link_placed|rejected)")
523
+ .action((backlinkId, status) =>
524
+ api.outreachStatus(backlinkId, status).then(out).catch(fail)
525
+ );
526
+
527
+ program
528
+ .command("network")
529
+ .description("Link Network credit stats, membership state, and hosted/received placements")
530
+ .action(() => api.linkNetwork().then(out).catch(fail));
531
+
532
+ program
533
+ .command("network-opt-in <on|off>")
534
+ .description("Join or leave the Link Network (dry run unless --confirm; leaving retires live links)")
535
+ .option("--confirm", "Actually change membership")
536
+ .action((state, opts) =>
537
+ api.linkNetworkOptIn(state === "on", !!opts.confirm).then(out).catch(fail)
538
+ );
539
+
540
+ program
541
+ .command("network-remove-placement <placementId>")
542
+ .description("Retire one network placement (dry run unless --confirm; visible on the partner site)")
543
+ .option("--confirm", "Actually remove it")
544
+ .action((placementId, opts) =>
545
+ api.linkNetworkRemovePlacement(placementId, !!opts.confirm).then(out).catch(fail)
546
+ );
547
+
548
+ program
549
+ .command("social")
550
+ .description("Social thread prospects (Reddit/X) with rules context and intent sort")
551
+ .option("--platform <reddit|twitter>")
552
+ .option("--status <status>")
553
+ .option("--age <ranked|new>")
554
+ .action((opts) =>
555
+ api
556
+ .socialThreads({ platform: opts.platform, status: opts.status, age: opts.age })
557
+ .then(out)
558
+ .catch(fail)
559
+ );
560
+
561
+ program
562
+ .command("social-stats")
563
+ .description("Social thread pipeline counts")
564
+ .action(() => api.socialStats().then(out).catch(fail));
565
+
566
+ program
567
+ .command("social-status <threadId> <status>")
568
+ .description("Move a social thread through the pipeline")
569
+ .action((threadId, status) =>
570
+ api.socialStatus(threadId, status).then(out).catch(fail)
571
+ );
572
+
573
+ program
574
+ .command("social-draft-reply <threadId>")
575
+ .description("AI-draft a reply for a thread (nothing posts; the draft lands for review)")
576
+ .option("--mention <none|natural|founderOpen>", "Brand-mention mode")
577
+ .action((threadId, opts) =>
578
+ api.socialDraftReply(threadId, opts.mention).then(out).catch(fail)
579
+ );
580
+
581
+ program
582
+ .command("support <subject>")
583
+ .description("Send a message or bug report to RankControl support (emails the team, capped 3/min)")
584
+ .requiredOption("--message <text>", "What happened — include errors, ids, and steps")
585
+ .option("--page <url>", "Related dashboard or API URL")
586
+ .action((subject, opts) =>
587
+ api.support({ subject, message: opts.message, pageUrl: opts.page }).then(out).catch(fail)
588
+ );
589
+
590
+ program
591
+ .command("brand")
592
+ .description("Brand profile, products, and buyer profiles (ICPs) in one read")
593
+ .action(() => api.brand().then(out).catch(fail));
594
+
595
+ program
596
+ .command("brand-profile-set")
597
+ .description("Patch brand voice/style fields. --json '{\"tone\":\"...\"}' or --json @file.json")
598
+ .requiredOption("--json <json>", "Fields: tone, primaryColor, secondaryColor, fontFamily, metaTitle, metaDescription, metaKeywords, defaultLocale, flexibleScheduling")
599
+ .action((opts) =>
600
+ api.brandProfileSet(parseJson(opts.json, "brand-profile-set")).then(out).catch(fail)
601
+ );
602
+
603
+ program
604
+ .command("brand-product")
605
+ .description("Create, update, or delete a product. Create: name+description+category; update: productId + fields; delete: productId + del:true")
606
+ .requiredOption("--json <json>", "Product JSON (or @file.json)")
607
+ .action((opts) =>
608
+ api.brandProductWrite(parseJson(opts.json, "brand-product")).then(out).catch(fail)
609
+ );
610
+
611
+ program
612
+ .command("brand-icp")
613
+ .description("Create, update, or delete a buyer profile. Create: title+industry+demographics; update: profileId + fields; delete: profileId + del:true")
614
+ .requiredOption("--json <json>", "Buyer profile JSON (or @file.json)")
615
+ .action((opts) =>
616
+ api.brandIcpWrite(parseJson(opts.json, "brand-icp")).then(out).catch(fail)
617
+ );
618
+
117
619
  program.parseAsync(argv);
118
620
  }