superx-cli 0.6.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 (2026-10-07)
4
+
5
+ - `posts:remix` gains `--author-handle <@handle>`, the handle that originally posted the text. Pass the account's own handle when remixing one of its own posts: the remix then keeps that post's subject and products instead of re-grounding them in the profile. The API (`author_handle` on `POST /v1/posts/remix`) and MCP `remix_post` take the same field
6
+ - Deleting a scheduled post (`scheduled:delete`, `scheduled:bulk-delete`, MCP `delete_scheduled_post` / `bulk_delete_scheduled_posts`) or moving one to draft (`scheduled:update --status draft`) now re-points scheduled posts that quote it to the post it quoted when that is one of your own posts (still scheduled earlier, or already published); otherwise they lose the quote
7
+ - Image-only posts: `scheduled:create` and `posts:publish` take `--media <key>` without `--text`, `scheduled:update` takes `--text "" --media <key>` (it replaces the whole post, so the empty text is explicit), and `--parts-json` parts may leave out `text` when they carry `media`. The API and the MCP `schedule_post`, `update_scheduled_post` and `publish_post` tools accept a part with media and no text the same way. Nothing accepted before changes; a post with no text and no media at all is still refused
8
+ - `inspiration:search` gains `--min-outlier-score <n>` (0-1000) and `--min-length <n>`. `outlier_score` is a post's engagement relative to what is expected for its author's follower count: 1.0 = expected, 3 = three times expected. `--min-outlier-score 3` keeps only posts at or above 3, and posts whose author's follower count is unknown are left out. `--min-length` keeps posts with at least that many characters
9
+ - MCP `find_inspiration` gained the same filters as the CLI and API: `min_followers` / `max_followers`, `min_outlier_score`, `sort` (including `outlier`), `min_reposts`, `min_replies`, `min_bookmarks`, `min_impressions`, `min_length`, `exclude_topics` and `page`. Each post now returns `outlier_score` and `bookmarks`, and the result carries `page` and `has_more`. Reconnect the server to see the new schema
10
+
11
+ ## 0.7.1 (2026-09-18)
12
+
13
+ - New skill Worth a Reply (worth-a-reply): `posts:triage` reads a topic, then the skill hands back the posts worth replying to, ordered by how much room there is to add something in a reply, each with its link, one line on why and the angle a reply could take. Skim posts come back as one-liners and the Pass pile stays out unless the person asks. It drafts nothing until they pick a post. Skills are now 32.
14
+
15
+ ## 0.7.0 (2026-09-18)
16
+
17
+ - `posts:triage "<query>"` searches recent public posts on a topic and sorts them into Read, Pass or Not sure, up to 40 posts a run. Every post comes back with its lane, a `pct`, a kind (insight, story, data, progress, news, question, intro, launch, joke or opinion) and the yes/no answers behind the call: specific and informative, says something new, engagement bait, a plug, a platitude, and room to add something in a reply. `pct` is how CLEAR the call was and not how good the post is: on `read` and `pass` it runs 50 to 99, so a `pass` at 99 is confidently not worth reading, and only on `unsure` is it the raw worth-reading score. Read it with the lane, never alone. `--days 1-7` sets how far back to look (default 3), `--account` attributes the run. What is judged is the TEXT of a post and never who wrote it, so it is a call on the writing, not a rating of a person. The search asks for original posts, English only, with a floor of 30 likes, and never returns replies or reposts. 2 credits a run, a daily cap per plan, and 1 live lookup for a single-word query or 2 for a multi-word one. Nothing is posted, saved or sent
18
+ - MCP `triage_posts` does the same thing with the same fields. Reconnect the server to pick it up
19
+
3
20
  ## 0.6.0 (2026-09-18)
4
21
 
5
22
  - Skills layout: the skill now lives at `skills/superx/`, one folder per SuperX skill under `skills/superx/references/skills/<id>/` (`recipe.md` for the agent, `card.json` for the app). `SKILL.md` is route-first and under 500 lines: the command list moved verbatim to `references/commands.md`, the growth strategy guide to `references/growth-strategy.md`, and the skills index is generated. The root `SKILL.md`, `PLAYBOOK.md` and `PLAYBOOKS.md` are gone, and "playbook" is now only the name of the SuperX blog freebie
package/README.md CHANGED
@@ -126,9 +126,10 @@ Every stored reply your audience has sent you across all your posts: the reply t
126
126
  superx inspiration:search "build in public" --limit 10
127
127
  superx inspiration:search "indie hackers" --sort outlier --min-likes 500
128
128
  superx inspiration:search "AI tools" --min-followers 1000 --max-followers 50000
129
+ superx inspiration:search "SaaS pricing" --min-outlier-score 3 --min-length 100
129
130
  ```
130
131
 
131
- Searches a library of 50M+ real high-performing posts by topic. Options: `--sort relevant|recent|likes|reposts|impressions|outlier`, `--min-likes/--min-reposts/--min-replies/--min-bookmarks/--min-impressions`, `--min-followers/--max-followers` (author size), `--since/--until` (UTC ISO-8601), `--lang` (default en), `--exclude-topics "crypto,politics"`, `--limit` (max 50), `--page` (1-7). Each result carries an `outlier_score`: how far the post outperformed the norm for its author's follower tier. Results are relevance-ranked, strongest matches first, with weak and promotional matches filtered out, so a page may return fewer than `--limit` posts. Use them for structures and hooks to remix, never to copy.
132
+ Searches a library of 50M+ real high-performing posts by topic. Options: `--sort relevant|recent|likes|reposts|impressions|outlier`, `--min-likes/--min-reposts/--min-replies/--min-bookmarks/--min-impressions`, `--min-length` (characters), `--min-followers/--max-followers` (author size), `--min-outlier-score` (0-1000), `--since/--until` (UTC ISO-8601), `--lang` (default en), `--exclude-topics "crypto,politics"`, `--limit` (max 50), `--page` (1-7). Each result carries an `outlier_score`: how far the post outperformed the norm for its author's follower tier (1.0 = expected, 3 = three times expected). `--min-outlier-score` keeps only posts at or above that score. Results are relevance-ranked, strongest matches first, with weak and promotional matches filtered out, so a page may return fewer than `--limit` posts. Use them for structures and hooks to remix, never to copy.
132
133
 
133
134
  ### Contacts (who engages with you)
134
135
 
@@ -206,7 +207,7 @@ superx scheduled:create --text "Post" --at "2026-08-01T15:00:00Z" --idempotency-
206
207
 
207
208
  superx scheduled:list --status draft,scheduled # draft | scheduled | sent | error
208
209
  superx scheduled:list --from "2026-08-01T00:00:00Z" --to "2026-08-08T00:00:00Z"
209
- superx scheduled:delete <post-id>
210
+ superx scheduled:delete <post-id> # posts quoting it re-point to its quoted post if yours (scheduled earlier or published), else lose the quote
210
211
  ```
211
212
 
212
213
  Images attach in two steps: upload, then reference the `object_key`.
@@ -217,7 +218,7 @@ superx scheduled:create --text "Chart of the week" --media "$KEY" --alt-text "We
217
218
  superx scheduled:create --parts-json '[{"text":"1/ Hook","media":[{"object_key":"'"$KEY"'"}]},{"text":"2/ Detail"}]'
218
219
  ```
219
220
 
220
- `media:upload` accepts JPG, PNG, and WEBP up to 5MB and GIF up to 15MB; a post part carries up to 4 images or exactly 1 GIF. `--media` takes a comma list of keys; `--alt-text` (max 1,000 chars) works with a single key, and `--parts-json` covers threads and per-image alt text. Uploads are capped at 100 per day and expire after 24 hours if never attached. Video is not supported yet.
221
+ `media:upload` accepts JPG, PNG, and WEBP up to 5MB and GIF up to 15MB; a post part carries up to 4 images or exactly 1 GIF. `--media` takes a comma list of keys; `--alt-text` (max 1,000 chars) works with a single key, and `--parts-json` covers threads and per-image alt text. A post can be image-only: `--media` without `--text` on `scheduled:create` and `posts:publish`, `--text "" --media <key>` on `scheduled:update`, or a `--parts-json` part with `media` and no `text`. Uploads are capped at 100 per day and expire after 24 hours if never attached. Video is not supported yet.
221
222
 
222
223
  Drafts can carry organizer fields: `--title` (max 300 chars) and `--scratchpad` (max 30,000 chars) are shown in the SuperX app and never posted; `--tag <id>` (repeatable, max 20) attaches tags from `tags:list`.
223
224
 
package/dist/index.js CHANGED
@@ -201,6 +201,10 @@ var SuperXAPI = class {
201
201
  async viralScore(body) {
202
202
  return (await this.request("/posts/viral-score", { method: "POST", body })).json;
203
203
  }
204
+ /** Sort recent public posts on a topic into Read, Pass or Not sure. */
205
+ async triage(body) {
206
+ return (await this.request("/posts/triage", { method: "POST", body })).json;
207
+ }
204
208
  /** Draft ONE reply to a post. Text only: a person posts it. */
205
209
  async draftReply(body) {
206
210
  return (await this.request("/engage/reply-draft", { method: "POST", body })).json;
@@ -796,6 +800,7 @@ async function postsRemix(argv) {
796
800
  closeness: argv.closeness
797
801
  };
798
802
  if (argv.instructions) body.instructions = argv.instructions;
803
+ if (argv.authorHandle) body.author_handle = argv.authorHandle;
799
804
  if (argv.account) body.account_id = argv.account;
800
805
  const api = new SuperXAPI(getConfig());
801
806
  printJson(await api.remixPost(body));
@@ -811,6 +816,23 @@ async function postsViralScore(argv) {
811
816
  const api = new SuperXAPI(getConfig());
812
817
  printJson(await api.viralScore(body));
813
818
  }
819
+ async function postsTriage(argv) {
820
+ if (!argv.query || !argv.query.trim()) {
821
+ note('Provide a query: superx posts:triage "coding agents".');
822
+ process.exit(1);
823
+ }
824
+ if (argv.days !== void 0) {
825
+ if (!Number.isInteger(argv.days) || argv.days < 1 || argv.days > 7) {
826
+ note("--days must be a whole number of days between 1 and 7.");
827
+ process.exit(1);
828
+ }
829
+ }
830
+ const body = { query: argv.query };
831
+ if (argv.days !== void 0) body.max_age_days = argv.days;
832
+ if (argv.account) body.account_id = argv.account;
833
+ const api = new SuperXAPI(getConfig());
834
+ printJson(await api.triage(body));
835
+ }
814
836
 
815
837
  // src/commands/tools.ts
816
838
  async function toolsInlineEdit(argv) {
@@ -853,6 +875,8 @@ async function inspirationSearch(argv) {
853
875
  min_impressions: argv.minImpressions,
854
876
  min_followers: argv.minFollowers,
855
877
  max_followers: argv.maxFollowers,
878
+ min_outlier_score: argv.minOutlierScore,
879
+ min_length: argv.minLength,
856
880
  since: argv.since,
857
881
  until: argv.until,
858
882
  lang: argv.lang,
@@ -1538,8 +1562,10 @@ function parsePartsJson(raw) {
1538
1562
  process.exit(1);
1539
1563
  throw new Error("unreachable");
1540
1564
  }
1541
- if (!Array.isArray(parsed) || parsed.some((p) => !p || typeof p !== "object" || typeof p.text !== "string")) {
1542
- note("--parts-json must be an array of { text, media? } objects.");
1565
+ if (!Array.isArray(parsed) || parsed.some(
1566
+ (p) => !p || typeof p !== "object" || typeof p.text !== "string" && !(p.text === void 0 && Array.isArray(p.media) && p.media.length > 0)
1567
+ )) {
1568
+ note("--parts-json must be an array of { text, media? } objects (text may be left out on a part with media).");
1543
1569
  process.exit(1);
1544
1570
  }
1545
1571
  return parsed;
@@ -1661,21 +1687,21 @@ async function scheduledCreate(argv) {
1661
1687
  note("Use exactly one of --text (single post), --part (thread), or --parts-json.");
1662
1688
  process.exit(1);
1663
1689
  }
1664
- if (sourceCount === 0) {
1690
+ if (sourceCount === 0 && argv.media === void 0) {
1665
1691
  note("Provide --text for a single post, --part flags for a thread, or --parts-json.");
1666
1692
  process.exit(1);
1667
1693
  }
1668
- if (argv.media !== void 0 && !argv.text) {
1694
+ if (argv.media !== void 0 && (parts.length > 0 || argv["parts-json"] !== void 0)) {
1669
1695
  note("--media applies to the --text single-post form. For threads, put media in --parts-json.");
1670
1696
  process.exit(1);
1671
1697
  }
1672
1698
  const body = {};
1673
1699
  if (argv["parts-json"] !== void 0) {
1674
1700
  body.parts = parsePartsJson(argv["parts-json"]);
1675
- } else if (argv.text) {
1701
+ } else if (argv.text || argv.media !== void 0) {
1676
1702
  const media = mediaFromFlags(argv.media, argv["alt-text"]);
1677
1703
  if (media) {
1678
- body.parts = [{ text: argv.text, media }];
1704
+ body.parts = [{ text: argv.text || "", media }];
1679
1705
  } else {
1680
1706
  body.text = argv.text;
1681
1707
  }
@@ -1707,8 +1733,12 @@ async function scheduledUpdate(argv) {
1707
1733
  note("Use exactly one of --text (single post), --part (thread), or --parts-json.");
1708
1734
  process.exit(1);
1709
1735
  }
1710
- if (argv.media !== void 0 && !argv.text) {
1711
- note("--media applies to the --text single-post form. For threads, put media in --parts-json.");
1736
+ if (argv.media !== void 0 && argv.text === void 0) {
1737
+ if (parts.length > 0 || argv["parts-json"] !== void 0) {
1738
+ note("--media applies to the --text single-post form. For threads, put media in --parts-json.");
1739
+ } else {
1740
+ note('--media replaces the whole post, text included. Pass --text with it, or --text "" for a media-only post.');
1741
+ }
1712
1742
  process.exit(1);
1713
1743
  }
1714
1744
  if (argv.title !== void 0 && argv["clear-title"]) {
@@ -1727,10 +1757,10 @@ async function scheduledUpdate(argv) {
1727
1757
  const body = {};
1728
1758
  if (argv["parts-json"] !== void 0) {
1729
1759
  body.parts = parsePartsJson(argv["parts-json"]);
1730
- } else if (argv.text) {
1760
+ } else if (argv.text || argv.media !== void 0) {
1731
1761
  const media = mediaFromFlags(argv.media, argv["alt-text"]);
1732
1762
  if (media) {
1733
- body.parts = [{ text: argv.text, media }];
1763
+ body.parts = [{ text: argv.text || "", media }];
1734
1764
  } else {
1735
1765
  body.text = argv.text;
1736
1766
  }
@@ -1773,21 +1803,21 @@ async function postsPublish(argv) {
1773
1803
  note("Use exactly one of --text (single post), --part (thread), or --parts-json.");
1774
1804
  process.exit(1);
1775
1805
  }
1776
- if (sourceCount === 0) {
1806
+ if (sourceCount === 0 && argv.media === void 0) {
1777
1807
  note("Provide --text for a single post, --part flags for a thread, or --parts-json.");
1778
1808
  process.exit(1);
1779
1809
  }
1780
- if (argv.media !== void 0 && !argv.text) {
1810
+ if (argv.media !== void 0 && (parts.length > 0 || argv["parts-json"] !== void 0)) {
1781
1811
  note("--media applies to the --text single-post form. For threads, put media in --parts-json.");
1782
1812
  process.exit(1);
1783
1813
  }
1784
1814
  const body = { scheduled_for: "now" };
1785
1815
  if (argv["parts-json"] !== void 0) {
1786
1816
  body.parts = parsePartsJson(argv["parts-json"]);
1787
- } else if (argv.text) {
1817
+ } else if (argv.text || argv.media !== void 0) {
1788
1818
  const media = mediaFromFlags(argv.media, argv["alt-text"]);
1789
1819
  if (media) {
1790
- body.parts = [{ text: argv.text, media }];
1820
+ body.parts = [{ text: argv.text || "", media }];
1791
1821
  } else {
1792
1822
  body.text = argv.text;
1793
1823
  }
@@ -2656,7 +2686,10 @@ var advancedSettingsOptions = (y) => y.option("auto-retweet", {
2656
2686
  }).option("instructions", {
2657
2687
  describe: "Extra direction for this remix (max 500 chars)",
2658
2688
  type: "string"
2659
- }).example('$0 posts:remix --text "$(cat post.txt)" --closeness 70', "A close rewrite in your voice").example('$0 posts:remix --text "..." --closeness 20 --instructions "make it a question"', "A loose reinterpretation").epilogue(
2689
+ }).option("author-handle", {
2690
+ describe: "The @handle that originally posted the text. Pass this account's own handle when remixing one of its own posts so the subject stays",
2691
+ type: "string"
2692
+ }).example('$0 posts:remix --text "$(cat post.txt)" --closeness 70', "A close rewrite in your voice").example('$0 posts:remix --text "..." --closeness 20 --instructions "make it a question"', "A loose reinterpretation").example('$0 posts:remix --text "$(cat old-post.txt)" --closeness 60 --author-handle @you', "Say one of your own posts again, same subject").epilogue(
2660
2693
  "Returns TEXT ONLY. Nothing is posted or scheduled: save the result with posts:draft or scheduled:create once you are happy with it."
2661
2694
  ),
2662
2695
  run(postsRemix)
@@ -2728,6 +2761,19 @@ var advancedSettingsOptions = (y) => y.option("auto-retweet", {
2728
2761
  "The score compares this draft with the account's OWN recent posts. It is not a reach prediction and it knows nothing about follower count. Rewrite and score again until the score stops rising, and read any warnings as a stop sign: asking for replies or sending readers off the platform can never raise it."
2729
2762
  ),
2730
2763
  run(postsViralScore)
2764
+ ).command(
2765
+ "posts:triage <query>",
2766
+ "Sort recent public posts on a topic into Read, Pass or Not sure (costs AI credits)",
2767
+ (y) => accountOption(y).positional("query", {
2768
+ describe: "Topic, phrase or search expression (2-80 characters)",
2769
+ type: "string"
2770
+ }).option("days", {
2771
+ describe: "How far back to search, 1 to 7 days (default 3)",
2772
+ type: "number"
2773
+ }).example('$0 posts:triage "coding agents"', "What is worth reading on a topic today").example('$0 posts:triage "indie SaaS" --days 7', "The past week instead of the past three days").epilogue(
2774
+ "Judges the TEXT of each post and never sees who wrote it, so read the result as a call on the writing rather than a rating of a person. Up to 40 posts a run, 2 credits, and each plan has a daily cap. Nothing is posted, saved or sent."
2775
+ ),
2776
+ run(postsTriage)
2731
2777
  ).command(
2732
2778
  "replies:list",
2733
2779
  "List replies the account has sent (newest first)",
@@ -2749,7 +2795,10 @@ var advancedSettingsOptions = (y) => y.option("auto-retweet", {
2749
2795
  describe: "Sort order",
2750
2796
  type: "string",
2751
2797
  choices: ["relevant", "recent", "likes", "reposts", "impressions", "outlier"]
2752
- }).option("min-likes", { describe: "Only posts with at least this many likes", type: "number" }).option("min-reposts", { describe: "Only posts with at least this many reposts", type: "number" }).option("min-replies", { describe: "Only posts with at least this many replies", type: "number" }).option("min-bookmarks", { describe: "Only posts with at least this many bookmarks", type: "number" }).option("min-impressions", { describe: "Only posts with at least this many impressions", type: "number" }).option("min-followers", { describe: "Only posts from authors with at least this many followers", type: "number" }).option("max-followers", { describe: "Only posts from authors with at most this many followers", type: "number" }).option("since", { describe: "Only posts after this time (UTC ISO-8601)", type: "string" }).option("until", { describe: "Only posts before this time (UTC ISO-8601)", type: "string" }).option("lang", { describe: "Language code (default en)", type: "string" }).option("exclude-topics", { describe: "Comma-separated topics to exclude", type: "string" }).example('$0 inspiration:search "build in public" --limit 10', "Ten posts about building in public").example('$0 inspiration:search "indie hackers" --sort outlier --min-likes 500', "Overperformers with 500+ likes"),
2798
+ }).option("min-likes", { describe: "Only posts with at least this many likes", type: "number" }).option("min-reposts", { describe: "Only posts with at least this many reposts", type: "number" }).option("min-replies", { describe: "Only posts with at least this many replies", type: "number" }).option("min-bookmarks", { describe: "Only posts with at least this many bookmarks", type: "number" }).option("min-impressions", { describe: "Only posts with at least this many impressions", type: "number" }).option("min-followers", { describe: "Only posts from authors with at least this many followers", type: "number" }).option("max-followers", { describe: "Only posts from authors with at most this many followers", type: "number" }).option("min-outlier-score", {
2799
+ describe: "Only posts with at least this outlier score (1.0 = expected engagement for the author's size, max 1000)",
2800
+ type: "number"
2801
+ }).option("min-length", { describe: "Only posts with at least this many characters", type: "number" }).option("since", { describe: "Only posts after this time (UTC ISO-8601)", type: "string" }).option("until", { describe: "Only posts before this time (UTC ISO-8601)", type: "string" }).option("lang", { describe: "Language code (default en)", type: "string" }).option("exclude-topics", { describe: "Comma-separated topics to exclude", type: "string" }).example('$0 inspiration:search "build in public" --limit 10', "Ten posts about building in public").example('$0 inspiration:search "indie hackers" --sort outlier --min-likes 500', "Overperformers with 500+ likes"),
2753
2802
  run(inspirationSearch)
2754
2803
  ).command(
2755
2804
  "inspiration:media [query]",
@@ -3365,7 +3414,7 @@ var advancedSettingsOptions = (y) => y.option("auto-retweet", {
3365
3414
  type: "string",
3366
3415
  array: true
3367
3416
  }).option("media", {
3368
- describe: "Comma list of image object_keys (from media:upload) to attach; single-post form only (max 4 images or 1 GIF)",
3417
+ describe: "Comma list of image object_keys (from media:upload) to attach; single-post form only (max 4 images or 1 GIF). Without --text it makes a media-only post",
3369
3418
  type: "string"
3370
3419
  }).option("alt-text", {
3371
3420
  describe: "Accessibility description for the attached image (single --media key only, max 1000 chars)",
@@ -3383,7 +3432,7 @@ var advancedSettingsOptions = (y) => y.option("auto-retweet", {
3383
3432
  }).option("idempotency-key", {
3384
3433
  describe: "Idempotency-Key header (max 64 chars); retries with the same key return the original result",
3385
3434
  type: "string"
3386
- }).example('$0 scheduled:create --text "Hello"', "Create a draft").example('$0 scheduled:create --text "Hello" --at "2026-08-01T15:00:00Z"', "Schedule a post").example('$0 scheduled:create --part "1/ Hook" --part "2/ Detail" --part "3/ CTA"', "Draft a 3-part thread").example('$0 scheduled:create --text "Hello" --title "Launch teaser" --tag abc123', "Draft with a title and a tag").example('$0 scheduled:create --text "Chart of the week" --media "<object_key>" --alt-text "Revenue chart"', "Draft with an image").example('$0 scheduled:create --text "Hello" --at "2026-08-01T15:00:00Z" --auto-retweet 6 --auto-retweet-remove 4', "Schedule with an auto retweet").example('$0 scheduled:create --text "Hello" --at "2026-08-01T15:00:00Z" --no-auto-retweet --no-auto-plug', "Schedule with your defaults off for this post"),
3435
+ }).example('$0 scheduled:create --text "Hello"', "Create a draft").example('$0 scheduled:create --text "Hello" --at "2026-08-01T15:00:00Z"', "Schedule a post").example('$0 scheduled:create --part "1/ Hook" --part "2/ Detail" --part "3/ CTA"', "Draft a 3-part thread").example('$0 scheduled:create --text "Hello" --title "Launch teaser" --tag abc123', "Draft with a title and a tag").example('$0 scheduled:create --text "Chart of the week" --media "<object_key>" --alt-text "Revenue chart"', "Draft with an image").example('$0 scheduled:create --media "<object_key>"', "Draft an image-only post (no text)").example('$0 scheduled:create --text "Hello" --at "2026-08-01T15:00:00Z" --auto-retweet 6 --auto-retweet-remove 4', "Schedule with an auto retweet").example('$0 scheduled:create --text "Hello" --at "2026-08-01T15:00:00Z" --no-auto-retweet --no-auto-plug', "Schedule with your defaults off for this post"),
3387
3436
  run(scheduledCreate)
3388
3437
  ).command(
3389
3438
  "scheduled:update <id>",
@@ -3393,7 +3442,7 @@ var advancedSettingsOptions = (y) => y.option("auto-retweet", {
3393
3442
  type: "string",
3394
3443
  array: true
3395
3444
  }).option("media", {
3396
- describe: "Comma list of image object_keys to attach with --text (full replace: re-list existing keys to keep them; --text without --media removes the post's media)",
3445
+ describe: `Comma list of image object_keys to attach with --text (full replace: re-list existing keys to keep them; --text without --media removes the post's media; --text "" with --media makes the post media-only)`,
3397
3446
  type: "string"
3398
3447
  }).option("alt-text", {
3399
3448
  describe: "Accessibility description for the attached image (single --media key only, max 1000 chars)",
@@ -3405,18 +3454,18 @@ var advancedSettingsOptions = (y) => y.option("auto-retweet", {
3405
3454
  describe: "New schedule time (UTC ISO-8601 with explicit Z or offset). On its own it never schedules a draft; add --status scheduled.",
3406
3455
  type: "string"
3407
3456
  }).option("status", {
3408
- describe: "Explicit transition; scheduled needs a future time (via --at or already set)",
3457
+ describe: "Explicit transition; scheduled needs a future time (via --at or already set). Moving a scheduled post to draft re-points scheduled posts that quote it to the post it quoted when that is one of your own posts (still scheduled earlier, or already published); otherwise they lose the quote",
3409
3458
  type: "string",
3410
3459
  choices: ["draft", "scheduled"]
3411
3460
  }).option("title", { describe: "New draft title (max 300 chars)", type: "string" }).option("clear-title", { describe: "Remove the title", type: "boolean" }).option("scratchpad", { describe: "New private notes (max 30000 chars)", type: "string" }).option("clear-scratchpad", { describe: "Remove the notes", type: "boolean" }).option("tag", {
3412
3461
  describe: "Replacement tag id set (repeat the flag, max 20; replaces ALL current tags)",
3413
3462
  type: "string",
3414
3463
  array: true
3415
- }).option("clear-tags", { describe: "Remove all tags", type: "boolean" }).example('$0 scheduled:update abc123 --title "Better hook"', "Retitle a draft, everything else untouched").example('$0 scheduled:update abc123 --at "2026-08-01T15:00:00Z" --status scheduled', "Promote a draft to the queue").example("$0 scheduled:update abc123 --status draft", "Pull a post back to drafts (quota refunds)").example("$0 scheduled:update abc123 --auto-delete 8 --auto-delete-threshold 500", "Add an auto delete to the post").example("$0 scheduled:update abc123 --no-auto-retweet", "Remove the post's auto retweet"),
3464
+ }).option("clear-tags", { describe: "Remove all tags", type: "boolean" }).example('$0 scheduled:update abc123 --title "Better hook"', "Retitle a draft, everything else untouched").example('$0 scheduled:update abc123 --at "2026-08-01T15:00:00Z" --status scheduled', "Promote a draft to the queue").example("$0 scheduled:update abc123 --status draft", "Pull a post back to drafts (quota refunds)").example("$0 scheduled:update abc123 --auto-delete 8 --auto-delete-threshold 500", "Add an auto delete to the post").example("$0 scheduled:update abc123 --no-auto-retweet", "Remove the post's auto retweet").example('$0 scheduled:update abc123 --text "" --media "<object_key>"', "Make the post image-only (removes its text)"),
3416
3465
  run(scheduledUpdate)
3417
3466
  ).command(
3418
3467
  "scheduled:delete <id>",
3419
- "Delete a draft or scheduled post by id",
3468
+ "Delete a draft or scheduled post by id. Scheduled posts that quote it are re-pointed to the post it quoted when that is one of your own posts (still scheduled earlier, or already published); otherwise they lose the quote",
3420
3469
  (y) => y.positional("id", { describe: "Post id (from scheduled:list or scheduled:create)", type: "string" }),
3421
3470
  run(scheduledDelete)
3422
3471
  ).command(
@@ -3427,7 +3476,7 @@ var advancedSettingsOptions = (y) => y.option("auto-retweet", {
3427
3476
  type: "string",
3428
3477
  array: true
3429
3478
  }).option("media", {
3430
- describe: "Comma list of image object_keys (from media:upload) to attach; single-post form only (max 4 images or 1 GIF)",
3479
+ describe: "Comma list of image object_keys (from media:upload) to attach; single-post form only (max 4 images or 1 GIF). Without --text it makes a media-only post",
3431
3480
  type: "string"
3432
3481
  }).option("alt-text", {
3433
3482
  describe: "Accessibility description for the attached image (single --media key only, max 1000 chars)",
@@ -3442,7 +3491,7 @@ var advancedSettingsOptions = (y) => y.option("auto-retweet", {
3442
3491
  }).option("idempotency-key", {
3443
3492
  describe: "REQUIRED (max 64 chars). Reuse the SAME key when retrying so a timed-out call cannot post twice; use a new key only for new content",
3444
3493
  type: "string"
3445
- }).example('$0 posts:publish --text "Shipping now." --idempotency-key launch-2026-09-07', "Publish a single post").example('$0 posts:publish --part "1/ Hook" --part "2/ Detail" --idempotency-key thread-42', "Publish a thread").example('$0 posts:publish --text "Shipping now." --auto-retweet 6 --idempotency-key launch-2026-09-07', "Publish with an auto retweet"),
3494
+ }).example('$0 posts:publish --text "Shipping now." --idempotency-key launch-2026-09-07', "Publish a single post").example('$0 posts:publish --part "1/ Hook" --part "2/ Detail" --idempotency-key thread-42', "Publish a thread").example('$0 posts:publish --media "<object_key>" --idempotency-key gif-2026-10-03', "Publish an image-only post").example('$0 posts:publish --text "Shipping now." --auto-retweet 6 --idempotency-key launch-2026-09-07', "Publish with an auto retweet"),
3446
3495
  run(postsPublish)
3447
3496
  ).command(
3448
3497
  "scheduled:bulk-retime",
@@ -3459,7 +3508,7 @@ var advancedSettingsOptions = (y) => y.option("auto-retweet", {
3459
3508
  run(scheduledBulkAutoRetweet)
3460
3509
  ).command(
3461
3510
  "scheduled:bulk-delete",
3462
- "Delete up to 100 QUEUED posts and refund their post quota (sent posts and drafts are left alone)",
3511
+ "Delete up to 100 QUEUED posts and refund their post quota (sent posts and drafts are left alone). Scheduled posts that quote them are re-pointed to the post each one quoted when that is one of your own posts (still scheduled earlier, or already published); otherwise they lose the quote",
3463
3512
  (y) => accountOption(y).option("ids", { describe: "Comma list of post ids (max 100, from scheduled:list)", type: "string" }).example("$0 scheduled:bulk-delete --ids abc,def", "Delete two queued posts"),
3464
3513
  run(scheduledBulkDelete)
3465
3514
  ).command(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superx-cli",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "SuperX CLI - command line interface to the SuperX API for Twitter/X growth: read posts and analytics, find engaged contacts, and schedule posts and threads",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -32,7 +32,7 @@ official website: https://superx.so
32
32
 
33
33
  **Rule 2: Read `references/growth-strategy.md` before creating any content.** This skill ships a growth strategy guide (`references/growth-strategy.md`, also inside the installed npm package). It tells you WHAT to post, WHEN, and WHY: the action hierarchy, out-of-network discovery, the engagement loop, and the failure modes that kill reach. The CLI gives you data and actions; the strategy guide gives you judgment. Do not schedule content without it. For a goal-shaped task ("give me my weekly recap", "DM the people who replied to this post"), open the matching skill under `references/skills/`: the named SuperX skills, each with its exact command chain, MCP tool names and stopping point.
34
34
 
35
- **Rule 3: Know the write constraints.** `scheduled:create` without `--at` creates a DRAFT (nothing publishes). With `--at` it schedules for that time. `scheduled:update` changes only the flags you pass, and a new `--at` alone never schedules a draft; add `--status scheduled` to promote. Writes work on your main account or any linked account (pass the same `--account` you used to read it); accounts shared with you by other people are read-only, and tags are workspace-wide. Images attach via `media:upload` then `--media` (JPG/PNG/WEBP up to 5MB, GIF up to 15MB; max 4 images or 1 GIF per post); video is not supported. Timestamps MUST be UTC ISO-8601 with an explicit `Z` or offset; naive timestamps are rejected with 400. `posts:publish` and `articles:publish` post to X IMMEDIATELY and irreversibly; treat them like hitting Publish in public and get human confirmation of the exact text unless the user already gave it. `posts:publish` also requires `--idempotency-key`, which you reuse verbatim on any retry. `posts:draft` writes post text in the user's voice and saves NOTHING: show the drafts, let the user pick and edit one, then pass the final text to `scheduled:create` yourself; it costs AI credits per draft, so ask for the count the user actually wants. `--voice mine` is the voice of the `--account` you pass (its own posts and style guide); shared accounts are refused.
35
+ **Rule 3: Know the write constraints.** `scheduled:create` without `--at` creates a DRAFT (nothing publishes). With `--at` it schedules for that time. `scheduled:update` changes only the flags you pass, and a new `--at` alone never schedules a draft; add `--status scheduled` to promote. Writes work on your main account or any linked account (pass the same `--account` you used to read it); accounts shared with you by other people are read-only, and tags are workspace-wide. Images attach via `media:upload` then `--media` (JPG/PNG/WEBP up to 5MB, GIF up to 15MB; max 4 images or 1 GIF per post), and a post can be image-only (`--media` with no `--text`; on `scheduled:update` use `--text "" --media KEY`, since an update replaces the whole post); video is not supported. Timestamps MUST be UTC ISO-8601 with an explicit `Z` or offset; naive timestamps are rejected with 400. `posts:publish` and `articles:publish` post to X IMMEDIATELY and irreversibly; treat them like hitting Publish in public and get human confirmation of the exact text unless the user already gave it. `posts:publish` also requires `--idempotency-key`, which you reuse verbatim on any retry. `posts:draft` writes post text in the user's voice and saves NOTHING: show the drafts, let the user pick and edit one, then pass the final text to `scheduled:create` yourself; it costs AI credits per draft, so ask for the count the user actually wants. `--voice mine` is the voice of the `--account` you pass (its own posts and style guide); shared accounts are refused.
36
36
 
37
37
  ---
38
38
 
@@ -189,6 +189,7 @@ scoping are in [references/skills/README.md](./references/skills/README.md).
189
189
 
190
190
  ### Replies & Engagement
191
191
  - **Reply Sprint**: The replies your posts got, each with a ready-to-send draft. -> references/skills/reply-sprint/recipe.md
192
+ - **Worth a Reply**: Let Jev read your niche and hand you the posts worth replying to today. -> references/skills/worth-a-reply/recipe.md
192
193
  - **Reply to Any Post**: Paste any post, get the context and a strong reply. -> references/skills/reply-to-any-post/recipe.md
193
194
  - **My Replies Report**: Which of your replies actually earn attention. -> references/skills/my-replies-report/recipe.md
194
195
  - **Who Is This Person?**: A fast read on any public account, plus your history with them. -> references/skills/who-is-this-person/recipe.md
@@ -81,11 +81,12 @@ superx replies:received --limit 20 # Replies the audience has se
81
81
  superx inspiration:search "build in public" --limit 10 # Topic search
82
82
  superx inspiration:search "indie hackers" --sort outlier # Biggest overperformers
83
83
  superx inspiration:search "AI tools" --min-likes 500 --min-followers 1000 --max-followers 50000
84
+ superx inspiration:search "SaaS pricing" --min-outlier-score 3 --min-length 100 # 3x+ expected engagement, 100+ chars
84
85
  ```
85
86
 
86
87
  - Searches a library of 50M+ real high-performing posts. Use results for structures, hooks, and angles to remix. Never copy them.
87
- - Flags: `--sort relevant|recent|likes|reposts|impressions|outlier`, `--min-likes/--min-reposts/--min-replies/--min-bookmarks/--min-impressions`, `--min-followers/--max-followers` (author size), `--since/--until`, `--lang` (default en), `--exclude-topics "crypto,politics"`, `--limit` (max 50), `--page` (1-7).
88
- - `outlier_score` on each result = how far the post outperformed the norm for its author's follower tier. Sorting by `outlier` surfaces content that won on substance, not audience size.
88
+ - Flags: `--sort relevant|recent|likes|reposts|impressions|outlier`, `--min-likes/--min-reposts/--min-replies/--min-bookmarks/--min-impressions`, `--min-length` (characters), `--min-followers/--max-followers` (author size), `--min-outlier-score` (0-1000), `--since/--until`, `--lang` (default en), `--exclude-topics "crypto,politics"`, `--limit` (max 50), `--page` (1-7).
89
+ - `outlier_score` on each result = how far the post outperformed the norm for its author's follower tier (1.0 = expected, 3 = three times expected). Sorting by `outlier` surfaces content that won on substance, not audience size; `--min-outlier-score` drops everything below a floor.
89
90
  - Results are relevance-ranked, strongest matches first. Weak and promotional matches are filtered out, so a page may return fewer than `--limit` posts.
90
91
 
91
92
  ### Live X lookups
@@ -358,6 +359,7 @@ superx engage:reply-draft --text "hot take about pricing" --handle levelsio --au
358
359
  # Rewrite a post, near or far from the original
359
360
  superx posts:remix --text "$(cat post.txt)" --closeness 70
360
361
  superx posts:remix --text "..." --closeness 20 --instructions "make it a question"
362
+ superx posts:remix --text "$(cat old-post.txt)" --closeness 60 --author-handle @you
361
363
 
362
364
  # Change one selected piece, keeping the surrounding style
363
365
  superx tools:inline-edit --text "the hook line" --full "$(cat post.txt)" --type hook
@@ -372,16 +374,21 @@ superx tools:factcheck --text "X has 600M daily active users"
372
374
  # Score a draft against this account's own normal post, then improve it
373
375
  superx posts:viral-score --text "$(cat draft.txt)"
374
376
  superx posts:viral-score --text "$(cat draft-v2.txt)" --image
377
+
378
+ # Sort what people are posting on a topic into Read, Pass or Not sure
379
+ superx posts:triage "coding agents"
380
+ superx posts:triage "indie SaaS" --days 7
375
381
  ```
376
382
 
377
383
  - **Every one of these returns TEXT and posts NOTHING.** `engage:reply-draft` writes a reply for a person to review and post; there is still no reply-sending command anywhere in the CLI. Show the draft, let the user edit it, and never say a reply went out.
378
384
  - `engage:reply-draft` takes exactly one of `--post <id>` (the API reads the post live, so the draft sees the real text, author and any quoted post) or `--text` with optional `--author` and `--handle`. Add `--thoughts` with what the USER wants to say - ask them, never invent an opinion for them - and `--tone engaging|humorous|creative|sarcastic|inspirational|concise`. `--post` also spends one live X lookup on top of the credit.
379
- - `posts:remix` needs `--closeness` 0-100: 0 keeps only the idea, 100 stays very close to the original wording. Use it on a proven post the user wants to say again in their own words, then save the result with `posts:draft` or `scheduled:create`.
385
+ - `posts:remix` needs `--closeness` 0-100: 0 keeps only the idea, 100 stays very close to the original wording. Use it on a proven post the user wants to say again in their own words, then save the result with `posts:draft` or `scheduled:create`. When the post is one of the account's OWN (from `posts:list`, analytics or an old post the user pastes), add `--author-handle` with that account's handle so the remix keeps its subject instead of re-grounding it in the profile.
380
386
  - `tools:inline-edit` needs `--instruction`, `--type`, or both, and works best with `--full` so the edit blends into the post around it. `--type` presets: grammar, translate, hook, details, concise, engaging, humorous, creative, sarcastic, inspirational.
381
387
  - `tools:rephrase` presets: improve, grammar, translate, hook, details, clarity, engaging, humorous, positive, creative, sarcastic, inspirational, concise. The style ones write in the user's voice; grammar, translate, clarity, details and concise stay mechanical.
382
388
  - `tools:factcheck` reports `result` (true, false or unknown), a one-sentence `comment` and the `sources` it read. It is a model's reading of a couple of search results, NOT a guarantee: show the sources and never present the verdict as settled.
383
389
  - `posts:viral-score` scores ONE draft from 0 to 100 against the account's OWN recent posts, with `helped` / `hurt` in plain English and an expected multiple per counter (`reposts_and_quotes` and `views` come back `confidence: "low"`, so say so). It is not a reach prediction and it knows nothing about follower count. Rewrite what `hurt` names, score again, and stop when the score stops rising: three or four rounds is the useful range. NEVER chase the score with reply bait - anything in `warnings` (asking for replies, inviting people to connect, a borrowed template, sending readers off the platform) can only push a score DOWN, so a warning means change the post, not work around it. The baseline is the account's originals from the past 90 days, minus the last 3 days whose numbers are still settling; `baseline.kind: "population"` means fewer than 10 of those were usable and the draft was scored against the average training post instead.
384
- - Costs are measured AI credits: typically 1 each, and 2 for a remix or a reply draft. None of them spends a live X request except `engage:reply-draft --post`.
390
+ - `posts:triage "<query>"` is the only one here that reads other people's posts: it searches recent public posts on a topic (`--days` 1-7, default 3) and sorts up to 40 of them into `lane` `read`, `unsure` or `pass`, each with `pct`, a `kind` (insight, story, data, progress, news, question, intro, launch, joke, opinion) and the `answers` behind the call. Quote a post's own answers when you say why it landed where it did, and never invent a reason. **`pct` is how CLEAR the call was, not how good the post is:** on `read` and `pass` it runs 50 to 99, so a `pass` at 99 means confidently NOT worth reading, and only on `unsure` is it the raw worth-reading score. Always say the lane with the number, never rank posts by `pct` across lanes, and never call a 90 a great post without checking the lane first. What is judged is the TEXT of a post and the model never sees who wrote it, so report it as a call on the writing, NOT as a rating of a person or an account. The search asks for original posts, English only, with a floor of 30 likes, and never returns replies or reposts: a small or brand-new topic can come back empty, which is an honest answer rather than a failure. The floor is what the search asks for, not a promise about `counts.likes`, so do not tell the user every post has 30+ likes. `posts_searched` says how many distinct posts the searches found before the 40-post cap. Nothing is posted, saved or sent.
391
+ - Costs are measured AI credits: typically 1 each, 2 for a remix or a reply draft, and a flat 2 for a triage run. None of them spends a live X request except `engage:reply-draft --post` and `posts:triage`, which spends the searches it runs; both also carry a per-plan daily cap.
385
392
 
386
393
  ### Workers (posts written for you on a schedule)
387
394
 
@@ -447,6 +454,7 @@ superx scheduled:delete <post-id>
447
454
  - Replays add `"replayed": true` to the JSON output and print a stderr note.
448
455
  - `scheduled:list` filters: `--status draft,scheduled,sent,error` (comma list), `--tags id,id` (any-of), `--from/--to` bounds on the scheduled time.
449
456
  - `media:upload` accepts JPG/PNG/WEBP (5MB) and GIF (15MB); a post part carries up to 4 images OR exactly 1 GIF. Uploads are capped at 100/day and expire after 24h if never attached.
457
+ - Image-only posts work: `--media <key>` with no `--text` (on `scheduled:update` pass `--text "" --media <key>`, since it replaces the whole post), or a `--parts-json` part with `media` and no `text`.
450
458
 
451
459
  ### Publishing now (irreversible)
452
460
 
@@ -10,6 +10,6 @@ superx scheduled:create --text "<approved text>" --idempotency-key "remix-<slug>
10
10
 
11
11
  `--mirror` copies the SHAPE of a proven post, not its words. Pick a reference with room for the account's own facts.
12
12
 
13
- MCP: `find_inspiration` -> `draft_post` -> `schedule_post`
13
+ MCP: `find_inspiration` (`sort: "outlier"`, optionally `min_outlier_score: 3` for posts with 3x the expected engagement for the author's size) -> `draft_post` -> `schedule_post`
14
14
 
15
15
  Stops at: reference posts plus drafts, for the person to pick from. 3 AI credits per draft written.
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "Worth a Reply",
3
+ "category": "replies-engagement",
4
+ "order": 15,
5
+ "subtitle": "Let Jev read your niche and hand you the posts worth replying to today.",
6
+ "summary": "Searches recent public posts on a topic and reads every one of them, then sorts them into three lanes: Read, the ones worth your time, Skim, the maybes, and Pass, which stays greyed out and is never listed unless you ask for it. The Read posts come back ordered by how much room there is to add something in a reply, each with its link, one line on why it is worth a reply and an angle you could take. It reads the words of a post and never who wrote it, so it is a call on the writing and not a rating of a person. Nothing is drafted or sent unless you ask.",
7
+ "howToAsk": [
8
+ "What should I reply to in AI agents today?",
9
+ "Find me 5 posts worth replying to about indie SaaS",
10
+ "Anything worth replying to on coding agents this week?"
11
+ ],
12
+ "provide": "a topic, and optionally how many days back (1 to 7).",
13
+ "get": "the posts worth a reply, each with a link, one line on why and an angle, then the Skim list.",
14
+ "chips": [
15
+ "2 credits on the API and CLI",
16
+ "Up to 40 posts",
17
+ "Judges the text, never the author",
18
+ "Nothing is sent"
19
+ ],
20
+ "launchPrompt": "Find me the posts worth replying to on a topic today. Search recent posts on it, sort them into Read, Skim and Pass, and show the Read posts first, ordered by how much room there is to add something in a reply: one post per line with its link, its kind, one line on why it is worth a reply and the angle a reply could take, all taken from that post's own answers and text. Then list Skim as short one-liners. Leave the Pass pile out unless I ask for it, and do not draft any replies until I pick one. Here is the topic: ",
21
+ "sampleKind": "recap"
22
+ }
@@ -0,0 +1,16 @@
1
+ # Worth a Reply
2
+
3
+ Read a topic the person cares about and hand back the posts worth replying to today, each with the angle a reply could take.
4
+
5
+ ```bash
6
+ superx posts:triage "<the topic>" --days 3 # --days 1-7, default 3; one run per topic per day is plenty
7
+ superx engage:reply-draft --post <id> --thoughts "<what the person wants to say>" # only after they pick one
8
+ ```
9
+
10
+ Read `lane`, `pct`, `kind` and `answers` from every post. Present the `read` lane first, ordered by `answers.r_reply_room` (how much room a knowledgeable reader has to add something), and order ONLY inside that lane: one line per post with its `url`, its `kind`, one line on why it is worth a reply built from that post's own answers (`r_specific` says it names something concrete, `r_new` says it is not the usual take, `r_reply_room` says there is something left to add), and one line on the angle a reply could take, drawn from the post's text. Then the `unsure` lane as short one-liners under a Skim heading. Leave the `pass` lane out entirely unless the person asks what was dropped, and then say how many and why in one sentence. Quote a post's own answers rather than inventing a reason, and never claim a post has a given like count from the search floor.
11
+
12
+ `pct` is how CLEAR the call was, not how good the post is: on `read` and `pass` it runs 50 to 99, so a `pass` at 99 means confidently not worth the time, and only on `unsure` is it the raw worth-reading score. Say the lane with the number, never rank posts by `pct` across lanes. What is judged is the TEXT of a post and the model never sees who wrote it, so report it as a call on the writing and never as a rating of a person or an account. The search asks for original posts, English only, with a floor of 30 likes, and returns no replies or reposts, so a small or brand-new topic can come back empty, which is an honest answer rather than a failure.
13
+
14
+ MCP: `triage_posts` -> `draft_reply` only when the person picks a post and says what they want to say
15
+
16
+ Stops at: a reply list, in chat, for the person to pick from. It drafts nothing until they choose a post, and nothing is ever sent. A run costs a flat 2 credits and spends 1 live X lookup for a single-word topic or 2 for a multi-word one, out of the account's daily allowance.