@m13v/s4l 1.6.197-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1314 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +497 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env bash
2
+ # Programmatic equivalent of the menu-bar "Update now & restart Claude Desktop" button
3
+ # (mcp/menubar/s4l_menubar.py::_mcpb_update_work). Pulls the latest .mcpb from
4
+ # GitHub releases, unzips it over the Claude Desktop extension dir in place, and
5
+ # restarts Claude so the new MCP server loads. Designed to be run over SSH on a
6
+ # .mcpb box (e.g. `ssh macstadium 'bash -s' < scripts/s4l_box_update.sh`), where
7
+ # npm/npx is absent so the `runtime action:update` (npx) path is dead.
8
+ #
9
+ # Flags:
10
+ # --check Print installed vs latest and exit (no download, no restart).
11
+ # --no-restart Download + unpack the new .mcpb but do NOT restart Claude.
12
+ # (default) Download + unpack + restart Claude.
13
+ #
14
+ # Exits: 0 ok / already current, 2 download failed, 3 unpack failed, 4 no install.
15
+ set -euo pipefail
16
+
17
+ # Resolve the Claude Desktop extension dir. Claude derives its name from the
18
+ # manifest author, so the id changed `local.mcpb.m13v.social-autoposter` ->
19
+ # `local.mcpb.s4l.ai.social-autoposter` when the author became "S4L.ai". A
20
+ # hardcoded id silently breaks the updater on every fresh install (the box
21
+ # reported "no .mcpb install" for exactly this reason). Glob for any
22
+ # `*social-autoposter` extension dir that actually has a manifest.json, newest
23
+ # first, so this keeps working across future renames.
24
+ # Scan EVERY "Claude*/Claude Extensions" root, not just plain "Claude/": a box
25
+ # whose Desktop build is renamed (e.g. the account-rotator's "Claude-mediar" /
26
+ # "Claude-m13vduck" variants) keeps its extension under that suffixed dir, and a
27
+ # plain-"Claude/" glob misses it entirely (the "no .mcpb install" exit-4 bug on
28
+ # those boxes). Mirrors the menu bar's _ext_dir glob. Pick the newest matching
29
+ # `*social-autoposter` dir that actually carries a manifest.json.
30
+ APP_SUPPORT="$HOME/Library/Application Support"
31
+ EXT_DIR=""
32
+ for d in "$APP_SUPPORT"/Claude*/"Claude Extensions"/*social-autoposter; do
33
+ [ -f "$d/manifest.json" ] || continue
34
+ if [ -z "$EXT_DIR" ] || [ "$d" -nt "$EXT_DIR" ]; then EXT_DIR="$d"; fi
35
+ done
36
+ # Last-resort fallback to the historical path so behavior is unchanged on old boxes.
37
+ [ -n "$EXT_DIR" ] || EXT_DIR="$APP_SUPPORT/Claude/Claude Extensions/local.mcpb.m13v.social-autoposter"
38
+ PY="/usr/bin/python3"
39
+
40
+ mode="run"
41
+ case "${1:-}" in
42
+ --check) mode="check" ;;
43
+ --no-restart) mode="no-restart" ;;
44
+ "") mode="run" ;;
45
+ *) echo "unknown flag: $1" >&2; exit 64 ;;
46
+ esac
47
+
48
+ [ -f "$EXT_DIR/manifest.json" ] || { echo "no .mcpb install at $EXT_DIR" >&2; exit 4; }
49
+
50
+ # CHANNEL (2026-07-02): a box on the `staging` channel tracks the newest release
51
+ # OVERALL (prereleases included), resolved from the releases LIST endpoint;
52
+ # `stable` keeps the exact historical releases/latest behavior. This script is
53
+ # often piped over SSH (`ssh box 'bash -s' < s4l_box_update.sh`) with no repo on
54
+ # PATH, so channel + latest resolution is a self-contained python block reading
55
+ # the same <state dir>/channel.json marker every other surface uses. It prints
56
+ # four space-separated tokens: "<channel> <tag> <version> <mcpb_url>".
57
+ STATE_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}"
58
+ RESOLVED="$(S4L_STATE_DIR="$STATE_DIR" "$PY" - <<'PYEOF' 2>/dev/null || true
59
+ import json, os, re, subprocess
60
+
61
+ state = os.environ.get("S4L_STATE_DIR") or os.path.join(os.path.expanduser("~"), ".social-autoposter-mcp")
62
+ try:
63
+ ch = (json.load(open(os.path.join(state, "channel.json"))) or {}).get("channel")
64
+ except Exception:
65
+ ch = None
66
+ channel = ch if ch in ("stable", "staging") else "stable"
67
+
68
+ REPO = "m13v/s4l"
69
+ LATEST_DL = "https://github.com/%s/releases/latest/download/social-autoposter.mcpb" % REPO
70
+ TAG_DL = "https://github.com/%s/releases/download/%s/social-autoposter.mcpb"
71
+
72
+ def curl(url):
73
+ r = subprocess.run(["/usr/bin/curl", "-fsSL", "-m", "15",
74
+ "-H", "Accept: application/vnd.github+json", url],
75
+ capture_output=True, text=True, timeout=20)
76
+ return r.stdout
77
+
78
+ def ver_key(v):
79
+ s = str(v).strip().lstrip("v")
80
+ core, _, pre = s.partition("-")
81
+ nums = [int(x) if x.isdigit() else 0 for x in core.split("+")[0].split(".")]
82
+ while len(nums) < 3:
83
+ nums.append(0)
84
+ if not pre:
85
+ return (nums[0], nums[1], nums[2], 1, 0)
86
+ m = re.findall(r"\d+", pre)
87
+ return (nums[0], nums[1], nums[2], 0, int(m[-1]) if m else 0)
88
+
89
+ tag = ""
90
+ if channel == "staging":
91
+ try:
92
+ rels = json.loads(curl("https://api.github.com/repos/%s/releases?per_page=30" % REPO) or "[]")
93
+ best = None
94
+ for r in (rels if isinstance(rels, list) else []):
95
+ if not isinstance(r, dict) or r.get("draft"):
96
+ continue
97
+ t = r.get("tag_name")
98
+ if not isinstance(t, str) or not t.lstrip("v")[:1].isdigit():
99
+ continue
100
+ k = ver_key(t)
101
+ if best is None or k > best[0]:
102
+ best = (k, t)
103
+ if best:
104
+ tag = best[1]
105
+ except Exception:
106
+ tag = ""
107
+ if not tag:
108
+ # stable, or staging fallback when the list endpoint failed -> track stable
109
+ try:
110
+ tag = (json.loads(curl("https://api.github.com/repos/%s/releases/latest" % REPO) or "{}") or {}).get("tag_name") or ""
111
+ except Exception:
112
+ tag = ""
113
+ if channel == "staging":
114
+ channel = "stable"
115
+
116
+ version = tag.lstrip("v")
117
+ url = LATEST_DL if channel == "stable" else (TAG_DL % (REPO, tag))
118
+ print("%s %s %s %s" % (channel, tag, version, url))
119
+ PYEOF
120
+ )"
121
+ CHANNEL="$(printf '%s' "$RESOLVED" | awk '{print $1}')"; [ -n "$CHANNEL" ] || CHANNEL="stable"
122
+ latest_tag="$(printf '%s' "$RESOLVED" | awk '{print $2}')"
123
+ latest="$(printf '%s' "$RESOLVED" | awk '{print $3}')"
124
+ MCPB_URL="$(printf '%s' "$RESOLVED" | awk '{print $4}')"
125
+ [ -n "$MCPB_URL" ] || MCPB_URL="https://github.com/m13v/s4l/releases/latest/download/social-autoposter.mcpb"
126
+
127
+ installed="$("$PY" -c "import json,sys;print((json.load(open(sys.argv[1])) or {}).get('version',''))" "$EXT_DIR/manifest.json" 2>/dev/null || true)"
128
+ echo "channel=$CHANNEL installed=$installed latest=$latest"
129
+
130
+ if [ "$mode" = "check" ]; then
131
+ [ -n "$latest" ] && [ "$installed" != "$latest" ] && echo "update_available=true" || echo "update_available=false"
132
+ exit 0
133
+ fi
134
+
135
+ if [ -n "$latest" ] && [ "$installed" = "$latest" ]; then
136
+ echo "already on latest ($installed); re-applying anyway would just restart Claude. skipping."
137
+ # Comment the next line out if you want a forced re-unpack even when current.
138
+ exit 0
139
+ fi
140
+
141
+ tmpd="$(mktemp -d -t s4l-update-XXXXXX)"
142
+ trap 'rm -rf "$tmpd"' EXIT
143
+ mcpb="$tmpd/social-autoposter.mcpb"
144
+
145
+ echo "downloading $MCPB_URL ..."
146
+ # Retry: a freshly-cut GitHub release's asset download endpoint 404s for up to a
147
+ # couple minutes while the CDN propagates (the release API shows the tag/asset as
148
+ # "uploaded" before the download URL serves it). A single curl loses that race and
149
+ # the update silently "fails." Retry with backoff so the standard pipeline is
150
+ # robust to that window.
151
+ sz=0
152
+ for attempt in 1 2 3 4 5 6 7 8 9 10; do
153
+ if curl -fLs -m 300 "$MCPB_URL" -o "$mcpb" 2>/dev/null; then
154
+ sz=$(stat -f%z "$mcpb" 2>/dev/null || echo 0)
155
+ [ "$sz" -ge 100000 ] && break
156
+ fi
157
+ echo " download attempt $attempt not ready yet (asset propagating); retrying in 15s..." >&2
158
+ sz=0
159
+ sleep 15
160
+ done
161
+ [ "$sz" -ge 100000 ] || { echo "download failed after retries (asset never became available)" >&2; exit 2; }
162
+
163
+ echo "unpacking into extension dir ..."
164
+ unzip -oq "$mcpb" -d "$EXT_DIR" || { echo "unpack failed" >&2; exit 3; }
165
+ new_ver="$("$PY" -c "import json,sys;print((json.load(open(sys.argv[1])) or {}).get('version',''))" "$EXT_DIR/manifest.json" 2>/dev/null || true)"
166
+ echo "unpacked version=$new_ver"
167
+
168
+ if [ "$mode" = "no-restart" ]; then
169
+ echo "done (no restart requested); restart Claude to load v$new_ver."
170
+ exit 0
171
+ fi
172
+
173
+ # Restart Claude. From an SSH session we skip the osascript graceful-quit the
174
+ # menu bar uses (it can trip an Automation TCC prompt for sshd and block
175
+ # unattended); killall sends SIGTERM and needs no automation grant.
176
+ echo "restarting Claude ..."
177
+ killall Claude 2>/dev/null || true
178
+ sleep 4
179
+ killall -9 Claude 2>/dev/null || true
180
+ sleep 1
181
+ # Relocate the autopilot scheduled tasks' working dir to ~/.s4l-worker so their
182
+ # once-a-minute runs stop flooding the user's interactive `claude --resume`
183
+ # history (Claude buckets sessions by cwd). MUST run while Claude is DOWN — the
184
+ # running app caches the scheduled-tasks registry in memory and clobbers a live
185
+ # edit on the next fire. Kept in sync with the menu-bar updater's
186
+ # _rewrite_scheduled_task_cwd() and queueWorkerCwd() in mcp/src/index.ts.
187
+ echo "relocating worker task cwd -> ~/.s4l-worker + removing deprecated autopilot ..."
188
+ /usr/bin/python3 - <<'PYCWD' 2>/dev/null || true
189
+ import json, os, glob, tempfile, shutil
190
+ home = os.path.expanduser("~")
191
+ worker = os.path.join(home, ".s4l-worker")
192
+ os.makedirs(worker, exist_ok=True)
193
+ # s4l-worker is the universal type-blind worker (2026-07-02); saps-worker
194
+ # (staging rc.2/rc.3) and the phase pair are legacy. This script only heals cwd
195
+ # here — the legacy->s4l-worker consolidation runs via the menubar's
196
+ # _rewrite_scheduled_task_cwd() self-heal.
197
+ WORKERS = {"s4l-worker", "saps-worker", "saps-phase1-query", "saps-phase2b-draft"}
198
+ DEPRECATED = {"social-autoposter-autopilot"}
199
+ pat = os.path.join(home, "Library/Application Support/Claude/claude-code-sessions/*/*/scheduled-tasks.json")
200
+ for f in glob.glob(pat):
201
+ try:
202
+ d = json.load(open(f))
203
+ except Exception:
204
+ continue
205
+ out, dirty = [], False
206
+ for t in d.get("scheduledTasks", []) or []:
207
+ tid = t.get("id")
208
+ if tid in DEPRECATED:
209
+ dirty = True; continue # drop deprecated autopilot
210
+ if tid in WORKERS and t.get("cwd") != worker:
211
+ t["cwd"] = worker; dirty = True
212
+ out.append(t)
213
+ if dirty:
214
+ d["scheduledTasks"] = out
215
+ try:
216
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(f))
217
+ with os.fdopen(fd, "w") as fh: json.dump(d, fh, indent=2)
218
+ os.replace(tmp, f)
219
+ print(" cwd-fix: updated", os.path.basename(os.path.dirname(f)))
220
+ except Exception as e:
221
+ print(" cwd-fix: write failed:", e)
222
+ for tid in DEPRECATED:
223
+ shutil.rmtree(os.path.join(home, ".claude", "scheduled-tasks", tid), ignore_errors=True)
224
+ PYCWD
225
+ open -a Claude 2>/dev/null || true
226
+ echo "done; Claude restarting on v$new_ver."
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ """Release-channel knob for a single S4L box (stable vs staging).
3
+
4
+ ONE tiny file that every update surface reads so a box can opt into pre-release
5
+ (`staging`) builds without affecting any other box:
6
+
7
+ - `stable` (default): the box tracks GitHub `releases/latest`, i.e. the newest
8
+ NON-prerelease release. This is the historical behavior and what every box
9
+ gets when this file / the channel marker is absent.
10
+ - `staging`: the box tracks the newest release OVERALL (prerelease RCs included).
11
+ A staging box is therefore always >= stable: it picks up each `-rc.N` first,
12
+ and once an RC is promoted to a full release it stays current on that too.
13
+
14
+ The channel lives in a single JSON marker in the state dir so the TypeScript MCP
15
+ server (mcp/src/version.ts), the menu-bar snapshot (scripts/snapshot.py), the
16
+ menu bar itself (mcp/menubar/s4l_menubar.py), and the SSH updater
17
+ (scripts/s4l_box_update.sh) all resolve the SAME value. Keep the filename and the
18
+ semantics in lockstep across those four surfaces.
19
+
20
+ CLI (SSH-drivable, zero deps beyond stdlib):
21
+ python3 scripts/s4l_channel.py get # -> stable | staging
22
+ python3 scripts/s4l_channel.py set staging # opt in
23
+ python3 scripts/s4l_channel.py set stable # opt back out
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import sys
30
+
31
+ VALID_CHANNELS = ("stable", "staging")
32
+ DEFAULT_CHANNEL = "stable"
33
+ CHANNEL_FILE = "channel.json"
34
+
35
+
36
+ def state_dir() -> str:
37
+ return os.environ.get("S4L_STATE_DIR") or os.path.join(
38
+ os.path.expanduser("~"), ".social-autoposter-mcp"
39
+ )
40
+
41
+
42
+ def channel_path() -> str:
43
+ return os.path.join(state_dir(), CHANNEL_FILE)
44
+
45
+
46
+ def read_channel() -> str:
47
+ """Current channel for this box. Any read error or unknown value falls back
48
+ to `stable` (fail-safe: a corrupt marker must never silently push a box onto
49
+ pre-release builds)."""
50
+ try:
51
+ with open(channel_path()) as f:
52
+ v = (json.load(f) or {}).get("channel")
53
+ if isinstance(v, str) and v.strip().lower() in VALID_CHANNELS:
54
+ return v.strip().lower()
55
+ except Exception:
56
+ pass
57
+ return DEFAULT_CHANNEL
58
+
59
+
60
+ def is_staging() -> bool:
61
+ return read_channel() == "staging"
62
+
63
+
64
+ def set_channel(channel: str) -> str:
65
+ channel = (channel or "").strip().lower()
66
+ if channel not in VALID_CHANNELS:
67
+ raise ValueError(
68
+ "invalid channel %r (want one of %s)" % (channel, ", ".join(VALID_CHANNELS))
69
+ )
70
+ d = state_dir()
71
+ os.makedirs(d, exist_ok=True)
72
+ tmp = channel_path() + ".tmp"
73
+ with open(tmp, "w") as f:
74
+ json.dump({"channel": channel}, f)
75
+ f.write("\n")
76
+ os.replace(tmp, channel_path())
77
+ return channel
78
+
79
+
80
+ def _main(argv) -> int:
81
+ if not argv or argv[0] in ("-h", "--help"):
82
+ print(__doc__.strip())
83
+ return 0
84
+ cmd = argv[0]
85
+ if cmd == "get":
86
+ print(read_channel())
87
+ return 0
88
+ if cmd == "set":
89
+ if len(argv) < 2:
90
+ print("usage: s4l_channel.py set <stable|staging>", file=sys.stderr)
91
+ return 2
92
+ try:
93
+ print(set_channel(argv[1]))
94
+ except ValueError as e:
95
+ print(str(e), file=sys.stderr)
96
+ return 2
97
+ return 0
98
+ print("unknown command: %s (want get|set)" % cmd, file=sys.stderr)
99
+ return 2
100
+
101
+
102
+ if __name__ == "__main__":
103
+ raise SystemExit(_main(sys.argv[1:]))
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # s4l-ctl: programmatic control of the running social-autoposter (S4L) plugin via
3
+ # its loopback tool server, for QA. Runs the same handlers as the in-chat MCP
4
+ # tools (POST /tool/<name>). MUST run ON the box (the loopback is 127.0.0.1-only),
5
+ # so over SSH use: ssh macstadium 'bash -s -- <subcommand> [args]' < scripts/s4l_ctl.sh
6
+ #
7
+ # Subcommands:
8
+ # status Dashboard snapshot (read-only).
9
+ # count Number of pending (unposted) drafts (read-only).
10
+ # drafts List pending drafts with their 1-based numbers (read-only).
11
+ # approve <n> [n...] Post the given card numbers. DESTRUCTIVE — requires --yes.
12
+ # approve-all Post EVERY pending card. DESTRUCTIVE — requires --yes.
13
+ #
14
+ # DESTRUCTIVE note: "approve" really posts replies to live X/Twitter threads. The
15
+ # write subcommands refuse to run unless --yes is present (no interactive prompt,
16
+ # because over piped SSH there is no tty). For host-level plugin UPDATE use the
17
+ # separate scripts/s4l_box_update.sh (different mechanism: works even when the
18
+ # loopback is down, and it restarts Claude).
19
+ set -euo pipefail
20
+
21
+ BATCH="review-queue"
22
+ PLAN="/tmp/twitter_cycle_plan_${BATCH}.json"
23
+ EP="$HOME/.social-autoposter-mcp/panel-endpoint.json"
24
+ PY="/usr/bin/python3"
25
+
26
+ YES=0; ARGS=()
27
+ for a in "$@"; do
28
+ case "$a" in --yes|-y) YES=1 ;; *) ARGS+=("$a") ;; esac
29
+ done
30
+ set -- ${ARGS[@]+"${ARGS[@]}"}
31
+ cmd="${1:-}"; [ $# -gt 0 ] && shift || true
32
+
33
+ [ -f "$EP" ] || { echo "no panel-endpoint.json — is Claude Desktop / the MCP running?" >&2; exit 1; }
34
+ URL="$("$PY" -c "import json;print(json.load(open('$EP'))['url'])")"
35
+ curl -s -m 3 "${URL}health" >/dev/null || { echo "loopback unreachable at $URL" >&2; exit 1; }
36
+
37
+ tool() { curl -s -m "${2:-900}" -X POST "${URL}tool/$1" -H 'Content-Type: application/json' -d "${3:-{}}"; }
38
+
39
+ pending_count() {
40
+ [ -f "$PLAN" ] || { echo 0; return; }
41
+ "$PY" -c "import json;d=json.load(open('$PLAN'));print(sum(1 for c in d.get('candidates',[]) if not c.get('posted')))"
42
+ }
43
+
44
+ case "$cmd" in
45
+ status)
46
+ tool dashboard 20 ; echo ;;
47
+ count)
48
+ echo "pending=$(pending_count)" ;;
49
+ drafts)
50
+ if [ ! -f "$PLAN" ]; then echo "no review-queue plan on box (0 drafts)"; exit 0; fi
51
+ "$PY" - "$PLAN" <<'PYEOF'
52
+ import json,sys
53
+ d=json.load(open(sys.argv[1]))
54
+ for i,c in enumerate(d.get("candidates",[]),1):
55
+ if c.get("posted"): continue
56
+ txt=(c.get("reply_text") or "").replace("\n"," ")
57
+ print(f"#{i:<4} @{(c.get('thread_author') or '?'):<18} {txt[:90]}")
58
+ PYEOF
59
+ echo "pending=$(pending_count)" ;;
60
+ approve)
61
+ [ $# -ge 1 ] || { echo "usage: approve <n> [n...] --yes" >&2; exit 64; }
62
+ nums="$(printf '%s\n' "$@" | paste -sd, -)"
63
+ if [ "$YES" != "1" ]; then
64
+ echo "REFUSING: 'approve $*' will POST those cards to live X. Re-run with --yes to confirm." >&2; exit 3; fi
65
+ echo "posting cards [$nums] ..."
66
+ tool post_drafts 900 "{\"batch_id\":\"$BATCH\",\"post\":[$nums]}" ; echo ;;
67
+ approve-all)
68
+ n="$(pending_count)"
69
+ if [ "$YES" != "1" ]; then
70
+ echo "REFUSING: approve-all will POST all $n pending cards to live X. Re-run with --yes to confirm." >&2; exit 3; fi
71
+ echo "posting all $n pending cards ..."
72
+ tool post_drafts 1800 "{\"batch_id\":\"$BATCH\",\"post_all\":true}" ; echo ;;
73
+ *)
74
+ echo "usage: s4l_ctl.sh {status|count|drafts|approve <n...>|approve-all} [--yes]" >&2; exit 64 ;;
75
+ esac
@@ -0,0 +1,47 @@
1
+ """SAPS_ -> S4L_ environment mirror (brand rename 2026-07-03).
2
+
3
+ The internal env-var prefix moved from SAPS_ to S4L_, but launchd plists,
4
+ Claude Desktop scheduled-task prompts, and cron entries on customer machines
5
+ were written before the rename and still export SAPS_* names. They cannot all
6
+ be regenerated instantly (plists are only rewritten on re-registration), so
7
+ every python entrypoint that launchd / scheduled tasks invoke directly calls
8
+ mirror() right after imports. It copies each SAPS_FOO to S4L_FOO when the new
9
+ name is unset, and (defensively, for old code launched by NEW plists that emit
10
+ only S4L_*) the reverse direction too. Existing values always win: mirror()
11
+ never overwrites a name that is already set.
12
+
13
+ Stdlib-only, safe under /usr/bin/python3. Usage:
14
+
15
+ import s4l_env
16
+ s4l_env.mirror()
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+
23
+ _OLD = "SAPS_"
24
+ _NEW = "S4L_"
25
+
26
+
27
+ def mirror(environ=None) -> int:
28
+ """Copy SAPS_* -> S4L_* (and S4L_* -> SAPS_*) for any name whose twin is
29
+ unset. Returns the number of variables copied. Never overwrites."""
30
+ env = environ if environ is not None else os.environ
31
+ copied = 0
32
+ # Snapshot keys first: we mutate env while iterating.
33
+ for key in list(env.keys()):
34
+ if key.startswith(_OLD):
35
+ twin = _NEW + key[len(_OLD):]
36
+ elif key.startswith(_NEW):
37
+ twin = _OLD + key[len(_NEW):]
38
+ else:
39
+ continue
40
+ if twin not in env:
41
+ env[twin] = env[key]
42
+ copied += 1
43
+ return copied
44
+
45
+
46
+ if __name__ == "__main__":
47
+ print(f"mirrored {mirror()} env var(s)")
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env python3
2
+ """saps_activity.py — shared writer for the menu-bar activity.json signal.
3
+
4
+ The S4L menu bar polls ``<state_dir>/activity.json`` every second and shows a
5
+ spinner + label while work is happening (scanning / drafting / posting / …).
6
+ Several lanes do work the menu bar should narrate, but historically only two of
7
+ them wrote this file:
8
+
9
+ - the MCP server (TypeScript ``writeActivity``) — for IN-CHAT tool calls only,
10
+ - ``twitter_post_plan.py`` — for per-post posting progress.
11
+
12
+ The unattended draft autopilot was invisible: the launchd kicker's scan phase
13
+ runs inside the (locked) ``run-twitter-cycle.sh`` with no writer, and Phase-2b
14
+ drafting is done by the queue provider (which only blocks) and the Claude Desktop
15
+ scheduled-task worker (which never wrote anything). So "scanning" and "drafting"
16
+ never showed on the box.
17
+
18
+ This module is the single Python writer those lanes share, keeping the JSON shape
19
+ and the state-dir resolution byte-identical to the TS + poster writers so the menu
20
+ bar reads one consistent signal regardless of who produced the work.
21
+
22
+ Purely cosmetic and fully best-effort: a failure here MUST never affect the work
23
+ it narrates. Every public call swallows its own exceptions.
24
+
25
+ State-dir resolution matches everything else: ``$S4L_STATE_DIR`` or
26
+ ``~/.social-autoposter-mcp``. The scheduled-task worker sets ``S4L_STATE_DIR``
27
+ in the env before calling in (see ``claude_job.py::_apply_state_dir_override``),
28
+ so the worker lane lands in the same dir the launchd kicker and menu bar use.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import os
35
+ from datetime import datetime, timezone
36
+
37
+
38
+ def state_dir() -> str:
39
+ return os.environ.get("S4L_STATE_DIR") or os.path.join(
40
+ os.path.expanduser("~"), ".social-autoposter-mcp"
41
+ )
42
+
43
+
44
+ def _path() -> str:
45
+ return os.path.join(state_dir(), "activity.json")
46
+
47
+
48
+ def write(state: str, label: str) -> None:
49
+ """Mirror the Node server's writeActivity shape: {state, label, since}.
50
+
51
+ Written atomically (tmp + os.replace) so the menu bar's 1s poll never reads a
52
+ half-written file. Best-effort: any failure is swallowed.
53
+ """
54
+ try:
55
+ sd = state_dir()
56
+ os.makedirs(sd, exist_ok=True)
57
+ payload = {
58
+ "state": state,
59
+ "label": label,
60
+ "since": datetime.now(timezone.utc).isoformat(),
61
+ }
62
+ target = _path()
63
+ tmp = f"{target}.tmp.{os.getpid()}"
64
+ with open(tmp, "w", encoding="utf-8") as f:
65
+ f.write(json.dumps(payload) + "\n")
66
+ os.replace(tmp, target)
67
+ except Exception:
68
+ pass
69
+
70
+
71
+ def read() -> dict | None:
72
+ """Current signal as a dict, or None when absent/unreadable. Best-effort."""
73
+ try:
74
+ with open(_path(), encoding="utf-8") as f:
75
+ return json.load(f)
76
+ except Exception:
77
+ return None
78
+
79
+
80
+ def heartbeat(state: str, label: str) -> None:
81
+ """Refresh `since` for a phase that's still ongoing, but ONLY if the current
82
+ signal is still that same phase (or there is none). This lets a shell lane
83
+ keep a long 'scanning' phase fresh against the menu bar's staleness TTL
84
+ WITHOUT fighting a later writer that has already advanced the phase: once the
85
+ queue provider flips the label to 'finding threads'/'drafting replies', the
86
+ state no longer matches and this goes quiet (no flicker between the two)."""
87
+ try:
88
+ cur = read()
89
+ if cur is None or cur.get("state") == state:
90
+ write(state, label)
91
+ except Exception:
92
+ pass
93
+
94
+
95
+ def clear() -> None:
96
+ """Remove the activity signal so no stuck 'scanning/drafting' lingers after a
97
+ cycle, a worker turn, or an early exit. Idempotent; safe to double-clear."""
98
+ try:
99
+ p = _path()
100
+ if os.path.exists(p):
101
+ os.remove(p)
102
+ except Exception:
103
+ pass
104
+
105
+
106
+ def _main(argv: list[str]) -> int:
107
+ # CLI used by shell lanes (run-draft-and-publish.sh):
108
+ # saps_activity.py write <state> <label words...>
109
+ # saps_activity.py heartbeat <state> <label words...> (conditional refresh)
110
+ # saps_activity.py clear
111
+ if not argv:
112
+ return 0
113
+ cmd = argv[0]
114
+ if cmd == "clear":
115
+ clear()
116
+ elif cmd in ("write", "heartbeat"):
117
+ state = argv[1] if len(argv) > 1 else "working"
118
+ label = " ".join(argv[2:]) if len(argv) > 2 else ""
119
+ (heartbeat if cmd == "heartbeat" else write)(state, label)
120
+ return 0
121
+
122
+
123
+ if __name__ == "__main__":
124
+ import sys
125
+
126
+ sys.exit(_main(sys.argv[1:]))