@m13v/s4l 1.6.197-rc.10

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 +1336 -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 +513 -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
package/bin/cli.js ADDED
@@ -0,0 +1,980 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const { spawnSync } = require('child_process');
8
+
9
+ const scheduler = require('./scheduler');
10
+ const { formatDoctorReport, runDoctorSync } = require('../mcp/shared/doctor.cjs');
11
+ const { recordDoctorReport } = require('../mcp/shared/onboarding-ledger.cjs');
12
+
13
+ const DEST = path.join(os.homedir(), 'social-autoposter');
14
+ const PKG_ROOT = path.join(__dirname, '..');
15
+ const HOME = os.homedir();
16
+
17
+ // Files/dirs to copy from npm package to ~/social-autoposter
18
+ const COPY_TARGETS = [
19
+ 'scripts',
20
+ 'config.example.json',
21
+ 'requirements.txt',
22
+ 'SKILL.md',
23
+ 'skill',
24
+ 'setup',
25
+ 'browser-agent-configs',
26
+ 'mcp-servers',
27
+ 'mcp',
28
+ ];
29
+
30
+ // Never overwrite these user files during update
31
+ const USER_FILES = new Set(['config.json', '.env', 'SKILL.md']);
32
+
33
+ // Browser agent config templates -> install path under ~/.claude/browser-agent-configs/
34
+ // twitter-harness replaces the retired twitter-agent (2026-05-19). The harness
35
+ // runs a CDP-driven real Chrome on port 9555 backed by an MCP stdio server at
36
+ // ~/.claude/mcp-servers/browser-harness/server.py. installBrowserHarness()
37
+ // below provisions the supporting bits (uv, browser-harness CLI, mcp pkg).
38
+ const BROWSER_AGENT_CONFIGS = [
39
+ 'reddit-agent-mcp.json',
40
+ 'reddit-agent.json',
41
+ 'linkedin-agent-mcp.json',
42
+ 'linkedin-agent.json',
43
+ 'twitter-harness-mcp.json',
44
+ 'linkedin-harness-mcp.json',
45
+ 'all-agents-mcp.json',
46
+ ];
47
+
48
+ const BROWSER_PROFILES = ['reddit', 'linkedin', 'browser-harness'];
49
+
50
+ function copyDir(src, dest) {
51
+ fs.mkdirSync(dest, { recursive: true });
52
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
53
+ const srcPath = path.join(src, entry.name);
54
+ const destPath = path.join(dest, entry.name);
55
+ if (entry.isDirectory()) {
56
+ copyDir(srcPath, destPath);
57
+ } else {
58
+ fs.copyFileSync(srcPath, destPath);
59
+ }
60
+ }
61
+ }
62
+
63
+ function linkOrRelink(target, linkPath) {
64
+ try { fs.rmSync(linkPath, { recursive: true, force: true }); } catch {}
65
+ fs.symlinkSync(target, linkPath);
66
+ }
67
+
68
+ // Locate uv (Astral's Python launcher). The browser-harness MCP server is
69
+ // shebanged through uv so it can pull `mcp` on first run without polluting
70
+ // the system Python. Returns the absolute path if found, or empty string.
71
+ function findUvBin() {
72
+ const candidates = [
73
+ path.join(HOME, '.local', 'bin', 'uv'),
74
+ '/opt/homebrew/bin/uv',
75
+ '/usr/local/bin/uv',
76
+ '/usr/bin/uv',
77
+ ];
78
+ for (const c of candidates) {
79
+ if (fs.existsSync(c)) return c;
80
+ }
81
+ const which = spawnSync('command', ['-v', 'uv'], { shell: true, encoding: 'utf8' });
82
+ const found = (which.stdout || '').trim().split('\n')[0];
83
+ return found && fs.existsSync(found) ? found : '';
84
+ }
85
+
86
+ // Locate the Python the MCP server is actually configured to run (S4L_PYTHON).
87
+ // mcp/install.mjs picks /opt/homebrew/bin/python3 (or /usr/local/bin/python3)
88
+ // and stamps it into the MCP config, so Python deps MUST be installed into that
89
+ // SAME interpreter. Bare `pip3`/`python3` on macOS usually resolves to the
90
+ // Xcode CLT system python (3.9.x with pip 21.x), which is both the wrong target
91
+ // and too old to understand --break-system-packages. Falls back to `python3`.
92
+ function findPythonBin() {
93
+ const candidates = ['/opt/homebrew/bin/python3', '/usr/local/bin/python3'];
94
+ for (const c of candidates) {
95
+ if (fs.existsSync(c)) return c;
96
+ }
97
+ const which = spawnSync('command', ['-v', 'python3'], { shell: true, encoding: 'utf8' });
98
+ const found = (which.stdout || '').trim().split('\n')[0];
99
+ return found && fs.existsSync(found) ? found : 'python3';
100
+ }
101
+
102
+ // True if `<pythonBin> -m pip` is new enough (pip >= 23.0) to accept
103
+ // --break-system-packages. Older pips treat the flag as an unknown option and
104
+ // hard-fail, so we must not pass it blindly on the retry.
105
+ function pipSupportsBreakSystemPackages(pythonBin) {
106
+ const v = spawnSync(pythonBin, ['-m', 'pip', '--version'], { encoding: 'utf8' });
107
+ const m = (v.stdout || '').match(/pip\s+(\d+)\.(\d+)/);
108
+ if (!m) return false;
109
+ return parseInt(m[1], 10) >= 23;
110
+ }
111
+
112
+ // True if the interpreter carries a PEP 668 EXTERNALLY-MANAGED marker in its
113
+ // stdlib dir (Homebrew python, Debian/Ubuntu 23+). On these, a bare
114
+ // `pip install` is GUARANTEED to fail with a loud "externally-managed-environment"
115
+ // wall of text. Detecting it up front lets pipInstall skip that doomed first
116
+ // attempt and go straight to --break-system-packages, so init output stays clean
117
+ // and doesn't falsely look like a failed dependency install when it recovers.
118
+ function pipIsExternallyManaged(pythonBin) {
119
+ const r = spawnSync(pythonBin, ['-c',
120
+ "import os,sys,sysconfig\n" +
121
+ "p=os.path.join(sysconfig.get_path('stdlib'),'EXTERNALLY-MANAGED')\n" +
122
+ "sys.exit(0 if os.path.exists(p) else 1)",
123
+ ]);
124
+ return r.status === 0;
125
+ }
126
+
127
+ // Install Python packages into a specific interpreter via `<py> -m pip install`.
128
+ // Behaviour by environment:
129
+ // - PEP 668 externally-managed interpreter (Homebrew python, Debian/Ubuntu 23+)
130
+ // with pip>=23: go STRAIGHT to --break-system-packages. The bare attempt
131
+ // would always fail loudly with externally-managed-environment, which made
132
+ // init look like "Python deps failed" even though the (silent) retry actually
133
+ // installed everything. No doomed first attempt, no false-alarm output.
134
+ // - Everything else: bare attempt, then retry with --break-system-packages only
135
+ // if it failed and pip supports the flag.
136
+ // Returns the spawnSync result of the last attempt.
137
+ function pipInstall(pythonBin, args) {
138
+ const base = ['-m', 'pip', 'install', ...args];
139
+ if (pipIsExternallyManaged(pythonBin) && pipSupportsBreakSystemPackages(pythonBin)) {
140
+ return spawnSync(pythonBin, [...base, '--break-system-packages'], { stdio: 'inherit' });
141
+ }
142
+ let r = spawnSync(pythonBin, base, { stdio: 'inherit' });
143
+ if (r.status !== 0 && pipSupportsBreakSystemPackages(pythonBin)) {
144
+ r = spawnSync(pythonBin, [...base, '--break-system-packages'], { stdio: 'inherit' });
145
+ }
146
+ return r;
147
+ }
148
+
149
+ function installBrowserAgentConfigs() {
150
+ const nodeBin = path.dirname(process.execPath);
151
+ const uvBin = findUvBin() || path.join(HOME, '.local', 'bin', 'uv');
152
+ const srcDir = path.join(PKG_ROOT, 'browser-agent-configs');
153
+ const destDir = path.join(HOME, '.claude', 'browser-agent-configs');
154
+ fs.mkdirSync(destDir, { recursive: true });
155
+
156
+ let installed = 0;
157
+ let skipped = 0;
158
+ for (const name of BROWSER_AGENT_CONFIGS) {
159
+ const src = path.join(srcDir, name);
160
+ const dest = path.join(destDir, name);
161
+ if (!fs.existsSync(src)) continue;
162
+ if (fs.existsSync(dest)) {
163
+ skipped++;
164
+ continue;
165
+ }
166
+ const tpl = fs.readFileSync(src, 'utf8');
167
+ const out = tpl
168
+ .replace(/__HOME__/g, HOME)
169
+ .replace(/__NODE_BIN__/g, nodeBin)
170
+ .replace(/__UV_BIN__/g, uvBin);
171
+ fs.writeFileSync(dest, out);
172
+ installed++;
173
+ }
174
+ console.log(` browser agent configs -> ${destDir} (installed ${installed}, skipped ${skipped} existing)`);
175
+
176
+ // Create empty persistent profile dirs so Playwright has somewhere to land cookies
177
+ const profilesDir = path.join(HOME, '.claude', 'browser-profiles');
178
+ fs.mkdirSync(profilesDir, { recursive: true });
179
+ for (const p of BROWSER_PROFILES) {
180
+ fs.mkdirSync(path.join(profilesDir, p), { recursive: true });
181
+ }
182
+ console.log(` browser profile dirs ready -> ${profilesDir}/{${BROWSER_PROFILES.join(',')}}`);
183
+ }
184
+
185
+ // Detect whether we are running inside an AppMaker E2B VM. AppMaker provisions
186
+ // a Chromium on port 9222 behind the SOAX residential proxy at 127.0.0.1:3003,
187
+ // and that Chromium is the one the user logs into via the AppMaker UI (profile
188
+ // /root/.chromium-profile). The browser-harness Chrome on port 9555 with its
189
+ // own (logged-out, un-proxied) profile is wrong for this host, so we:
190
+ // 1. skip installBrowserHarness() entirely (saves disk + avoids a second
191
+ // headless Chrome ever spawning).
192
+ // 2. write ~/.social-autoposter-env so skill/lib/twitter-backend.sh sources
193
+ // TWITTER_CDP_URL=http://127.0.0.1:9222 instead of the default 9555.
194
+ // Detection: presence of /opt/startup.sh (the AppMaker bootstrap script that
195
+ // only exists on these VMs) AND a live HTTP response on 127.0.0.1:9222.
196
+ function isAppMakerVm() {
197
+ if (process.platform !== 'linux') return false;
198
+ if (!fs.existsSync('/opt/startup.sh')) return false;
199
+ // Probe Chromium DevTools on 9222. 2s timeout; if it answers, we're on AppMaker.
200
+ const probe = spawnSync('curl', ['-sf', '--max-time', '2', '-o', '/dev/null', 'http://127.0.0.1:9222/json/version'], { stdio: 'ignore' });
201
+ return probe.status === 0;
202
+ }
203
+
204
+ // VM / AppMaker support is strictly opt-in. A normal `init`/`update` (the
205
+ // macOS user path) installs none of it — no apt-get, no :9222 CDP env file, no
206
+ // AppMaker MCP port overrides. It activates only when explicitly requested:
207
+ // - env SA_VM=1 (or SOCIAL_AUTOPOSTER_VM=1)
208
+ // - flag --vm on the command line
209
+ // - a persisted marker written by `bootstrap-vm` (so later `update`s on the
210
+ // same VM stay in VM mode without re-passing the flag)
211
+ // - a genuine AppMaker VM (linux + /opt/startup.sh + live :9222) — kept as a
212
+ // fallback so the existing mk0r bootstrap keeps working untouched. This can
213
+ // never be true on a user's Mac.
214
+ const VM_MARKER = path.join(HOME, '.social-autoposter', 'vm-mode');
215
+ function vmModeEnabled() {
216
+ if (process.env.SA_VM === '1' || process.env.SOCIAL_AUTOPOSTER_VM === '1') return true;
217
+ if (process.argv.includes('--vm')) return true;
218
+ try { if (fs.existsSync(VM_MARKER)) return true; } catch { /* ignore */ }
219
+ return isAppMakerVm();
220
+ }
221
+ function enableVmMode() {
222
+ try {
223
+ fs.mkdirSync(path.dirname(VM_MARKER), { recursive: true });
224
+ fs.writeFileSync(VM_MARKER, 'enabled\n');
225
+ } catch { /* best-effort; vmModeEnabled() still honors env/flag/probe */ }
226
+ }
227
+
228
+ // Write ~/.social-autoposter-env so skill/lib/twitter-backend.sh picks up the
229
+ // AppMaker-specific TWITTER_CDP_URL before its `${VAR:-default}` fallback hits.
230
+ // Idempotent: rewrites the file every invocation so a config edit on the VM
231
+ // can't drift away from what cli.js intends.
232
+ function writeAppMakerEnvFile(handleFromDb) {
233
+ const envPath = path.join(HOME, '.social-autoposter-env');
234
+ // Source of truth for the handle is the DB (social_accounts.handle keyed by
235
+ // vm_session_key). bootstrap-vm passes it in. Fallback: preserve a previously
236
+ // set value across rewrites if no DB-sourced handle was provided (matters
237
+ // when this runs from `social-autoposter update` without a fresh DB fetch).
238
+ let preservedHandle = String(handleFromDb || '').trim().replace(/^@/, '');
239
+ if (!preservedHandle) {
240
+ try {
241
+ const prev = fs.readFileSync(envPath, 'utf8');
242
+ const m = prev.match(/^\s*export\s+AUTOPOSTER_TWITTER_HANDLE=(.+)\s*$/m);
243
+ if (m) preservedHandle = m[1].trim();
244
+ } catch { /* no prior file */ }
245
+ }
246
+
247
+ const lines = [
248
+ '# social-autoposter per-host env overrides',
249
+ '# Auto-generated by social-autoposter init/update on AppMaker E2B VMs.',
250
+ '# Edit by hand only if you know what you are doing; it gets rewritten on every update.',
251
+ '',
252
+ '# Point twitter pipeline at AppMaker\'s proxied Chromium (SOAX residential exit',
253
+ '# at 127.0.0.1:3003) instead of the harness Chrome on 9555. The Chromium on',
254
+ '# 9222 is the one the user logs into via the AppMaker UI.',
255
+ 'export TWITTER_CDP_URL="http://127.0.0.1:9222"',
256
+ '',
257
+ '# AppMaker VMs run as root and the appmaker template sets Claude defaultMode',
258
+ '# to bypassPermissions. Claude CLI refuses bypassPermissions under root for',
259
+ '# security reasons UNLESS IS_SANDBOX=1 is set. Without this, every `claude -p`',
260
+ '# call in the pipeline exits immediately with no output (cost=$0.00, 16s) and',
261
+ '# Phase 1 reports envelope parse error / phase1_no_tweets.',
262
+ 'export IS_SANDBOX=1',
263
+ '',
264
+ ];
265
+ if (preservedHandle) {
266
+ lines.push(
267
+ '# Which Twitter handle this sandbox posts as. Durable home for the handle',
268
+ '# because config.json is reseeded on E2B sandbox substitution. Read by',
269
+ '# twitter_account.resolve_handle() (cycle scoping + session restore).',
270
+ `export AUTOPOSTER_TWITTER_HANDLE=${preservedHandle}`,
271
+ '',
272
+ );
273
+ }
274
+ const body = lines.join('\n');
275
+ fs.writeFileSync(envPath, body);
276
+ console.log(` AppMaker VM detected -> wrote ${envPath} (TWITTER_CDP_URL=http://127.0.0.1:9222${preservedHandle ? `, AUTOPOSTER_TWITTER_HANDLE=${preservedHandle}` : ''})`);
277
+ }
278
+
279
+ // AppMaker VMs: symlink /root/.chromium-profile → ~/.claude/browser-profiles/browser-harness
280
+ // so the appmaker-managed Chrome on port 9222 (launched by /opt/startup.sh with
281
+ // --user-data-dir=/root/.chromium-profile) actually opens the HARNESS profile,
282
+ // which is where our @<handle> Twitter login lives. Without this symlink, the
283
+ // appmaker Chrome opens a fresh empty profile and the pipeline talks to a
284
+ // logged-out browser. Combined with ENABLE_ROOT_VOLUME=1 on the Cloud Run
285
+ // host, the profile (and its cookies) now survives sandbox substitution.
286
+ // Idempotent: if already symlinked correctly, no-op. If a real directory
287
+ // exists, back it up (so any local-only browser cache isn't lost) and replace.
288
+ function linkAppMakerHarnessProfile() {
289
+ const harnessProfile = path.join(HOME, '.claude', 'browser-profiles', 'browser-harness');
290
+ const appmakerProfile = '/root/.chromium-profile';
291
+ try {
292
+ fs.mkdirSync(harnessProfile, { recursive: true });
293
+ let stat = null;
294
+ try { stat = fs.lstatSync(appmakerProfile); } catch { /* not present */ }
295
+ if (stat && stat.isSymbolicLink()) {
296
+ const target = fs.readlinkSync(appmakerProfile);
297
+ if (target === harnessProfile) {
298
+ console.log(` AppMaker profile already symlinked: ${appmakerProfile} -> ${harnessProfile}`);
299
+ return;
300
+ }
301
+ fs.unlinkSync(appmakerProfile);
302
+ } else if (stat && stat.isDirectory()) {
303
+ const backup = `${appmakerProfile}.replaced-by-symlink-${Date.now()}`;
304
+ fs.renameSync(appmakerProfile, backup);
305
+ console.log(` backed up existing ${appmakerProfile} -> ${backup}`);
306
+ }
307
+ fs.symlinkSync(harnessProfile, appmakerProfile);
308
+ console.log(` symlinked ${appmakerProfile} -> ${harnessProfile} (login persists across sandbox substitution)`);
309
+ } catch (e) {
310
+ console.warn(` WARNING: failed to symlink AppMaker profile: ${e.message}`);
311
+ }
312
+ }
313
+
314
+ // AppMaker VMs also need the twitter-harness MCP server (browser-harness/server.py)
315
+ // to drive port 9222, not its default 9555. That's a SECOND path the env file alone
316
+ // doesn't cover, because the MCP server is spawned by Claude as a subprocess with
317
+ // an env block taken from the MCP config file (--strict-mcp-config replaces the
318
+ // inherited env, so a parent BH_PORT export wouldn't reach it). So we patch the
319
+ // MCP config in-place to bake BH_PORT=9222 into its env block.
320
+ // Idempotent: parses the JSON, sets env.BH_PORT, rewrites. Safe to re-run.
321
+ function applyAppMakerMcpConfigOverrides() {
322
+ const cfgPath = path.join(HOME, '.claude', 'browser-agent-configs', 'twitter-harness-mcp.json');
323
+ if (!fs.existsSync(cfgPath)) {
324
+ console.log(` AppMaker MCP override: ${cfgPath} not found, skipping (will be picked up next run)`);
325
+ return;
326
+ }
327
+ let cfg;
328
+ try {
329
+ cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
330
+ } catch (e) {
331
+ console.warn(` AppMaker MCP override: failed to parse ${cfgPath}: ${e.message}`);
332
+ return;
333
+ }
334
+ const srv = cfg?.mcpServers?.['twitter-harness'];
335
+ if (!srv) {
336
+ console.warn(` AppMaker MCP override: ${cfgPath} has no mcpServers.twitter-harness entry`);
337
+ return;
338
+ }
339
+ if (!srv.env || typeof srv.env !== 'object') srv.env = {};
340
+ if (srv.env.BH_PORT === '9222') {
341
+ console.log(` AppMaker MCP override: BH_PORT=9222 already set in ${cfgPath}`);
342
+ return;
343
+ }
344
+ srv.env.BH_PORT = '9222';
345
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
346
+ console.log(` AppMaker MCP override: set BH_PORT=9222 in ${cfgPath}`);
347
+ }
348
+
349
+ // Provision the browser-harness toolchain that backs the twitter-harness MCP:
350
+ // 1. install uv (Astral) if missing
351
+ // 2. git-clone browser-use/browser-harness
352
+ // 3. uv tool install -e . (provides the `browser-harness` CLI)
353
+ // 4. ensure `mcp` Python package is importable for server.py
354
+ // 5. copy our shipped server.py into ~/.claude/mcp-servers/browser-harness/
355
+ // All steps are idempotent.
356
+ //
357
+ // AppMaker VMs: the toolchain is STILL needed (the MCP server.py is what
358
+ // Claude invokes during Phase 1's tweet scan, and it requires uv + mcp +
359
+ // browser-harness CLI to run). The AppMaker-specific deltas are:
360
+ // (a) writeAppMakerEnvFile() points TWITTER_CDP_URL at 9222 for posting
361
+ // (b) applyAppMakerMcpConfigOverrides() injects BH_PORT=9222 so server.py
362
+ // drives the AppMaker Chromium instead of trying to launch its own
363
+ // Chrome on 9555. server.py's ensure_chrome() short-circuits when CDP
364
+ // is already alive on PORT, so no double-Chrome ever spawns.
365
+ // Previously we early-returned here on AppMaker, which left the VM without
366
+ // uv installed and broke Phase 1's Claude scan (the MCP server's `command:
367
+ // /root/.local/bin/uv` resolved to ENOENT, Claude got no tools, returned an
368
+ // empty envelope).
369
+
370
+ function installBrowserHarness() {
371
+ const onAppMaker = vmModeEnabled();
372
+ if (onAppMaker) {
373
+ console.log(' AppMaker VM detected -> installing harness toolchain (deps); MCP will be pointed at port 9222');
374
+ writeAppMakerEnvFile();
375
+ // scripts/run_claude.sh uses `uuidgen` for session IDs on AUP-retry. The
376
+ // base image ships libuuid1 (shared lib) but not the CLI tool — the
377
+ // package is `uuid-runtime`. Without it, run_claude.sh's session_id
378
+ // generation falls back to empty string and claude --session-id breaks.
379
+ console.log(' installing uuid-runtime (uuidgen) for run_claude.sh...');
380
+ spawnSync('bash', ['-lc', 'command -v uuidgen >/dev/null 2>&1 || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq uuid-runtime'], { stdio: 'inherit' });
381
+ linkAppMakerHarnessProfile();
382
+ }
383
+ console.log(' setting up browser-harness (twitter-harness MCP backend)...');
384
+
385
+ // Step 1: uv. Try the official installer first; fall back to pip.
386
+ let uvBin = findUvBin();
387
+ if (!uvBin) {
388
+ console.log(' uv not found -> installing via Astral installer');
389
+ const sh = spawnSync('bash', ['-lc', 'curl -LsSf https://astral.sh/uv/install.sh | sh'], { stdio: 'inherit' });
390
+ if (sh.status !== 0) {
391
+ console.log(' Astral installer failed; falling back to pip3 install uv');
392
+ let pip = spawnSync('pip3', ['install', '-q', 'uv'], { stdio: 'inherit' });
393
+ if (pip.status !== 0) {
394
+ pip = spawnSync('pip3', ['install', '-q', 'uv', '--break-system-packages'], { stdio: 'inherit' });
395
+ }
396
+ }
397
+ uvBin = findUvBin();
398
+ }
399
+ if (!uvBin) {
400
+ console.warn(' WARNING: uv install failed; twitter-harness MCP server.py will not start.');
401
+ console.warn(' Install manually: curl -LsSf https://astral.sh/uv/install.sh | sh');
402
+ } else {
403
+ console.log(` uv -> ${uvBin}`);
404
+ }
405
+
406
+ // Step 2 + 3: clone + `uv tool install -e .` browser-harness.
407
+ //
408
+ // PINNED to a known-good upstream commit instead of tracking origin/HEAD.
409
+ // The installer used to fetch+reset --hard to HEAD on every run, so any
410
+ // upstream change shipped to users untested (this is how the two-blank-tab
411
+ // regression in upstream daemon.py attach behavior could reach users). Our
412
+ // launch-at-real-URL fix in server.py/twitter-backend.sh neutralizes that
413
+ // class of bug regardless, but pinning stops surprise upstream drift. Bump
414
+ // BROWSER_HARNESS_PIN deliberately after validating a newer upstream against
415
+ // the shipped server.py contract.
416
+ const BROWSER_HARNESS_PIN = '6d20866664ea3d9691b27bbf64f42ae097437dc3';
417
+ const harnessDir = path.join(HOME, 'Developer', 'browser-harness');
418
+ const pinHarness = () => {
419
+ // Fetch the exact pinned commit (GitHub serves arbitrary SHAs) and hard-
420
+ // reset onto it. Works for a fresh clone and an existing checkout alike.
421
+ const fetch = spawnSync('git', ['-C', harnessDir, 'fetch', '--depth', '1', 'origin', BROWSER_HARNESS_PIN], { stdio: 'inherit' });
422
+ if (fetch.status !== 0) {
423
+ console.warn(` WARNING: could not fetch pinned browser-harness commit ${BROWSER_HARNESS_PIN.slice(0, 9)}; using existing checkout.`);
424
+ return;
425
+ }
426
+ const reset = spawnSync('git', ['-C', harnessDir, 'reset', '--hard', 'FETCH_HEAD'], { stdio: 'inherit' });
427
+ if (reset.status !== 0) {
428
+ console.warn(' WARNING: could not reset browser-harness clone to pinned commit; using existing checkout.');
429
+ }
430
+ };
431
+ if (!fs.existsSync(harnessDir)) {
432
+ fs.mkdirSync(path.dirname(harnessDir), { recursive: true });
433
+ console.log(' cloning browser-harness from GitHub...');
434
+ const clone = spawnSync('git', ['clone', '--depth', '1', 'https://github.com/browser-use/browser-harness', harnessDir], { stdio: 'inherit' });
435
+ if (clone.status !== 0) {
436
+ console.warn(' WARNING: git clone failed; twitter-harness will not work until you clone manually.');
437
+ } else {
438
+ console.log(` pinning browser-harness to ${BROWSER_HARNESS_PIN.slice(0, 9)}...`);
439
+ pinHarness();
440
+ }
441
+ } else {
442
+ console.log(` browser-harness clone exists -> ${harnessDir}; pinning to ${BROWSER_HARNESS_PIN.slice(0, 9)}...`);
443
+ pinHarness();
444
+ }
445
+
446
+ if (uvBin && fs.existsSync(harnessDir)) {
447
+ console.log(' installing browser-harness CLI via uv tool...');
448
+ // --force so a refreshed source / changed entry point is reinstalled even
449
+ // when the tool is already present (a plain re-install is otherwise a no-op).
450
+ const install = spawnSync(uvBin, ['tool', 'install', '--force', '-e', harnessDir], { stdio: 'inherit' });
451
+ if (install.status !== 0) {
452
+ console.warn(' WARNING: `uv tool install -e .` failed; check the output above.');
453
+ }
454
+ // The harness daemon caches imported code in a long-running process; drop it
455
+ // so the next bh_run loads the freshly-installed CLI instead of stale code.
456
+ const harnessBin = path.join(HOME, '.local', 'bin', 'browser-harness');
457
+ if (fs.existsSync(harnessBin)) {
458
+ spawnSync(harnessBin, ['--reload'], { stdio: 'inherit' });
459
+ }
460
+
461
+ // Contract check: server.py pipes the script to browser-harness via stdin.
462
+ // Upstream supports two banner shapes — older builds advertise `-c <script>`
463
+ // and newer builds advertise the `<<'PY' ... PY` heredoc form. Either is
464
+ // fine for our use case (we pass the script via stdin, which both accept).
465
+ // Fail loudly if the installed binary advertises NEITHER, which usually
466
+ // means an offline/partial clone left a broken CLI that will silently make
467
+ // every bh_run look like "CDP not connected".
468
+ if (fs.existsSync(harnessBin)) {
469
+ const probe = spawnSync(harnessBin, [], { stdio: 'pipe', encoding: 'utf8', timeout: 15000 });
470
+ const usage = `${probe.stdout || ''}${probe.stderr || ''}`;
471
+ const supportsDashC = /\b-c\b/.test(usage);
472
+ const supportsStdin = /<<'PY'|<<"PY"|<<PY\b/.test(usage);
473
+ if (!supportsDashC && !supportsStdin) {
474
+ console.error(' ERROR: installed browser-harness CLI advertises neither `-c` nor a stdin heredoc.');
475
+ console.error(' This usually means a partial/corrupted install. The twitter-harness MCP will');
476
+ console.error(' return a usage banner / "CDP not connected" on every call.');
477
+ console.error(` Fix: rm -rf ${harnessDir} && re-run \`social-autoposter init\` while online,`);
478
+ console.error(' or manually: git clone https://github.com/browser-use/browser-harness ' + harnessDir +
479
+ ' && ' + uvBin + ' tool install --force -e ' + harnessDir);
480
+ } else {
481
+ const shape = supportsStdin ? 'stdin heredoc' : '-c flag';
482
+ console.log(` browser-harness CLI verified (${shape}).`);
483
+ }
484
+ }
485
+ }
486
+
487
+ // Step 4: ensure mcp Python package available (server.py uses `from mcp.server.fastmcp ...`).
488
+ // server.py is shebanged through `uv run --with mcp ...` so this is belt-and-suspenders;
489
+ // we install it into the S4L_PYTHON interpreter (the same Homebrew python the MCP
490
+ // server is configured to use), NOT bare pip3 which targets the Xcode CLT system python.
491
+ const harnessPython = findPythonBin();
492
+ console.log(` ensuring mcp>=1.0.0 Python package is importable (${harnessPython})...`);
493
+ const pip = pipInstall(harnessPython, ['-q', 'mcp>=1.0.0']);
494
+ if (pip.status !== 0) {
495
+ console.warn(' WARNING: could not install mcp Python package; server.py still runs via `uv run --with mcp`.');
496
+ }
497
+
498
+ // Step 5: copy our shipped server.py into the canonical install location.
499
+ const srcServer = path.join(PKG_ROOT, 'mcp-servers', 'browser-harness', 'server.py');
500
+ const destServer = path.join(HOME, '.claude', 'mcp-servers', 'browser-harness', 'server.py');
501
+ if (fs.existsSync(srcServer)) {
502
+ fs.mkdirSync(path.dirname(destServer), { recursive: true });
503
+ fs.copyFileSync(srcServer, destServer);
504
+ try { fs.chmodSync(destServer, 0o755); } catch {}
505
+ console.log(` server.py -> ${destServer}`);
506
+ } else {
507
+ console.warn(` WARNING: package missing mcp-servers/browser-harness/server.py (${srcServer})`);
508
+ }
509
+ }
510
+
511
+ // Register the three browser-agent MCP servers with Claude so they show up
512
+ // under user scope (writes to ~/.claude.json). Idempotent: parses the output
513
+ // of `claude mcp list` and only calls `add-json` for missing entries.
514
+ // If the `claude` CLI is not on PATH, prints manual instructions and returns.
515
+ function registerBrowserAgentMcpServers() {
516
+ const configDir = path.join(HOME, '.claude', 'browser-agent-configs');
517
+ // twitter-agent retired 2026-05-19, replaced by twitter-harness (CDP-driven
518
+ // real Chrome on port 9555 via the browser-harness MCP server).
519
+ const servers = [
520
+ { name: 'reddit-agent', file: path.join(configDir, 'reddit-agent-mcp.json') },
521
+ { name: 'linkedin-agent', file: path.join(configDir, 'linkedin-agent-mcp.json') },
522
+ { name: 'twitter-harness', file: path.join(configDir, 'twitter-harness-mcp.json') },
523
+ ];
524
+
525
+ const claudeBin = spawnSync('claude', ['--version'], { stdio: 'pipe' });
526
+ if (claudeBin.status !== 0) {
527
+ console.log(' claude CLI not on PATH; skipping MCP registration.');
528
+ console.log(' Once Claude Code is installed, register manually with:');
529
+ for (const s of servers) {
530
+ console.log(` claude mcp add-json ${s.name} "$(jq -c .mcpServers['\\"'${s.name}'\\"'] ${s.file})"`);
531
+ }
532
+ return;
533
+ }
534
+
535
+ const list = spawnSync('claude', ['mcp', 'list'], { encoding: 'utf8' });
536
+ const existing = list.status === 0 ? list.stdout : '';
537
+
538
+ let added = 0;
539
+ let skipped = 0;
540
+ for (const s of servers) {
541
+ if (!fs.existsSync(s.file)) {
542
+ console.warn(` MCP config missing: ${s.file}`);
543
+ continue;
544
+ }
545
+ // `claude mcp list` prints one server per line starting with the name.
546
+ // Use a word-boundary check so e.g. reddit-agent does not false-match linkedin-agent.
547
+ const re = new RegExp(`(^|\\s)${s.name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}(:|\\s|$)`, 'm');
548
+ if (re.test(existing)) {
549
+ skipped++;
550
+ continue;
551
+ }
552
+ const tpl = JSON.parse(fs.readFileSync(s.file, 'utf8'));
553
+ const stanza = tpl.mcpServers && tpl.mcpServers[s.name];
554
+ if (!stanza) {
555
+ console.warn(` ${s.file} has no mcpServers.${s.name} stanza; skipping`);
556
+ continue;
557
+ }
558
+ const r = spawnSync('claude', ['mcp', 'add-json', s.name, JSON.stringify(stanza)], { stdio: 'pipe', encoding: 'utf8' });
559
+ if (r.status === 0) {
560
+ added++;
561
+ } else {
562
+ console.warn(` claude mcp add-json ${s.name} failed: ${(r.stderr || r.stdout || '').trim()}`);
563
+ }
564
+ }
565
+ console.log(` MCP servers registered with Claude (added ${added}, already present ${skipped})`);
566
+ }
567
+
568
+ function generatePlists() {
569
+ const nodeBin = path.dirname(process.execPath);
570
+ const jobs = [
571
+ {
572
+ file: 'com.m13v.social-stats.plist',
573
+ label: 'com.m13v.social-stats',
574
+ script: `${DEST}/skill/stats.sh`,
575
+ interval: 21600,
576
+ runAtLoad: false,
577
+ stdoutLog: `${DEST}/skill/logs/launchd-stats-stdout.log`,
578
+ stderrLog: `${DEST}/skill/logs/launchd-stats-stderr.log`,
579
+ },
580
+ {
581
+ // Daily self-updater. Pulls + installs the latest published release so a
582
+ // hands-free / headless install never drifts stale. The script refuses to
583
+ // touch a .git dev checkout, so it is a safe no-op on a source box.
584
+ file: 'com.m13v.social-autoposter-update.plist',
585
+ label: 'com.m13v.social-autoposter-update',
586
+ script: `${DEST}/skill/social-autoposter-update.sh`,
587
+ interval: 86400,
588
+ runAtLoad: true,
589
+ stdoutLog: `${DEST}/skill/logs/launchd-self-update-stdout.log`,
590
+ stderrLog: `${DEST}/skill/logs/launchd-self-update-stderr.log`,
591
+ },
592
+ {
593
+ // On-screen overlay watcher supervisor. The overlay (harness status banner)
594
+ // only renders WHILE harness_overlay.py watch runs. The supervisor is
595
+ // idempotent (pgrep guard), so a 60s StartInterval
596
+ // is a no-op while the watcher is up and re-spawns it within a minute if it
597
+ // ever dies. RunAtLoad starts it right after install. This is what makes the
598
+ // overlay appear on headless / remote installs (Lane A); the MCP covers the
599
+ // pure-.mcpb lane by calling the same script on draft_cycle / autopilot.
600
+ file: 'com.m13v.social-overlay-watch.plist',
601
+ label: 'com.m13v.social-overlay-watch',
602
+ script: `${DEST}/skill/run-overlay-watch.sh`,
603
+ interval: 60,
604
+ runAtLoad: true,
605
+ stdoutLog: `${DEST}/skill/logs/launchd-overlay-watch-stdout.log`,
606
+ stderrLog: `${DEST}/skill/logs/launchd-overlay-watch-stderr.log`,
607
+ },
608
+ {
609
+ // Read-only LinkedIn presence lane. Uses the same linkedin-harness Chrome
610
+ // and browser locks as the main LinkedIn pipelines, but only views first-
611
+ // party surfaces and performs bounded scroll passes.
612
+ file: 'com.m13v.social-linkedin-presence.plist',
613
+ label: 'com.m13v.social-linkedin-presence',
614
+ script: `${DEST}/skill/linkedin-presence.sh`,
615
+ interval: 7200,
616
+ runAtLoad: false,
617
+ stdoutLog: `${DEST}/skill/logs/launchd-linkedin-presence-stdout.log`,
618
+ stderrLog: `${DEST}/skill/logs/launchd-linkedin-presence-stderr.log`,
619
+ },
620
+ ];
621
+
622
+ const driver = scheduler.driverFor();
623
+ const env = driver.defaultEnv({ home: HOME, nodeBin });
624
+ const outDir = path.join(DEST, 'launchd');
625
+ driver.generate({ jobs, outDir, env });
626
+ console.log(` generated launchd units at ${outDir}`);
627
+ }
628
+
629
+ function init() {
630
+ console.log('Setting up social-autoposter in', DEST);
631
+ fs.mkdirSync(DEST, { recursive: true });
632
+
633
+ // Copy all package files
634
+ for (const f of COPY_TARGETS) {
635
+ const src = path.join(PKG_ROOT, f);
636
+ const dest = path.join(DEST, f);
637
+ if (!fs.existsSync(src)) continue;
638
+ const stat = fs.statSync(src);
639
+ if (stat.isDirectory()) {
640
+ copyDir(src, dest);
641
+ } else {
642
+ fs.copyFileSync(src, dest);
643
+ }
644
+ console.log(' copied', f);
645
+ }
646
+
647
+ // Generate launchd plists with user's actual HOME
648
+ generatePlists();
649
+
650
+ // Provision the browser-harness toolchain BEFORE writing harness configs so
651
+ // findUvBin() picks up a freshly-installed uv on first run.
652
+ installBrowserHarness();
653
+ // Install browser agent MCP configs + profile dirs (skips existing files)
654
+ installBrowserAgentConfigs();
655
+ // On AppMaker VMs, patch the twitter-harness MCP config so its server.py
656
+ // drives port 9222 (AppMaker Chromium) instead of the default 9555.
657
+ if (vmModeEnabled()) applyAppMakerMcpConfigOverrides();
658
+ // Register those MCP servers with Claude so they show up in `claude mcp list`.
659
+ registerBrowserAgentMcpServers();
660
+
661
+ // config.json — only if it doesn't exist
662
+ const configDest = path.join(DEST, 'config.json');
663
+ if (!fs.existsSync(configDest)) {
664
+ fs.copyFileSync(path.join(PKG_ROOT, 'config.example.json'), configDest);
665
+ console.log(' created config.json from template');
666
+ } else {
667
+ console.log(' config.json exists — skipping');
668
+ }
669
+
670
+ // No .env is created. X/Twitter and the rest of the pipeline run with zero
671
+ // keys — state syncs through the s4l.ai HTTP API and the browser session
672
+ // lives in the harness Chrome profile. Optional integrations read their keys
673
+ // straight from the environment when set (MOLTBOOK_API_KEY for Moltbook,
674
+ // AUTOPOSTER_API_KEY only if your s4l.ai install uses a bearer token); every
675
+ // script guards `.env` with `[ -f .env ]`, so its absence is a no-op.
676
+
677
+ installPythonDeps();
678
+ removeLegacyEngagementStylesSidecar();
679
+ installMcp();
680
+
681
+ // Remove stale skill/SKILL.md if it exists (SKILL.md lives at repo root only)
682
+ const skillMd = path.join(DEST, 'skill', 'SKILL.md');
683
+ try { fs.rmSync(skillMd, { force: true }); } catch {}
684
+
685
+ // Skill symlinks — point to repo root so Claude loads SKILL.md directly
686
+ const skillsDir = path.join(os.homedir(), '.claude', 'skills');
687
+ fs.mkdirSync(skillsDir, { recursive: true });
688
+ linkOrRelink(DEST, path.join(skillsDir, 'social-autoposter'));
689
+ console.log(' ~/.claude/skills/social-autoposter ->', DEST);
690
+ linkOrRelink(path.join(DEST, 'setup'), path.join(skillsDir, 'social-autoposter-setup'));
691
+ console.log(' ~/.claude/skills/social-autoposter-setup ->', path.join(DEST, 'setup'));
692
+
693
+ console.log('');
694
+ console.log('Done! Next steps:');
695
+ console.log(' 1. Fully quit and relaunch Claude so the MCP loads');
696
+ console.log(' 2. Tell your Claude agent: "set me up on social-autoposter plugin end to end"');
697
+ console.log(' The agent will configure the product, connect X, seed topics, and verify a draft cycle');
698
+ console.log(' 3. Posts and all pipeline state sync via the s4l.ai HTTP API (no Postgres required)');
699
+ }
700
+
701
+ function update() {
702
+ if (!fs.existsSync(DEST)) {
703
+ console.error('Not installed. Run: npx social-autoposter init');
704
+ process.exit(1);
705
+ }
706
+
707
+ console.log('Updating social-autoposter...');
708
+
709
+ for (const f of COPY_TARGETS) {
710
+ if (USER_FILES.has(f)) {
711
+ console.log(' skipping', f, '(user file)');
712
+ continue;
713
+ }
714
+ const src = path.join(PKG_ROOT, f);
715
+ const dest = path.join(DEST, f);
716
+ if (!fs.existsSync(src)) continue;
717
+ const stat = fs.statSync(src);
718
+ if (stat.isDirectory()) {
719
+ copyDir(src, dest);
720
+ } else {
721
+ fs.copyFileSync(src, dest);
722
+ }
723
+ console.log(' updated', f);
724
+ }
725
+
726
+ // Regenerate launchd plists with correct paths
727
+ generatePlists();
728
+
729
+ // Provision browser-harness (uv + clone + uv tool install + mcp pkg + server.py).
730
+ // Idempotent: skips steps that are already done.
731
+ installBrowserHarness();
732
+ // Top up browser agent configs (won't overwrite user customizations)
733
+ installBrowserAgentConfigs();
734
+ // On AppMaker VMs, patch the twitter-harness MCP config so its server.py
735
+ // drives port 9222 (AppMaker Chromium) instead of the default 9555.
736
+ if (vmModeEnabled()) applyAppMakerMcpConfigOverrides();
737
+ // Register any newly added MCP servers with Claude (idempotent).
738
+ registerBrowserAgentMcpServers();
739
+
740
+ // Refresh Python deps every update so version-bumps land on existing installs
741
+ // and the candidate-style sidecar gets merged (preserves VM-side candidates).
742
+ installPythonDeps();
743
+ removeLegacyEngagementStylesSidecar();
744
+ installMcp();
745
+
746
+ // Remove stale skill/SKILL.md if it exists (SKILL.md lives at repo root only)
747
+ const skillMd = path.join(DEST, 'skill', 'SKILL.md');
748
+ try { fs.rmSync(skillMd, { force: true }); } catch {}
749
+
750
+ // Re-symlink skills — point to repo root so Claude loads SKILL.md directly
751
+ const skillsDir = path.join(os.homedir(), '.claude', 'skills');
752
+ try {
753
+ linkOrRelink(DEST, path.join(skillsDir, 'social-autoposter'));
754
+ console.log(' re-linked ~/.claude/skills/social-autoposter');
755
+ } catch {}
756
+ try {
757
+ linkOrRelink(path.join(DEST, 'setup'), path.join(skillsDir, 'social-autoposter-setup'));
758
+ console.log(' re-linked ~/.claude/skills/social-autoposter-setup');
759
+ } catch {}
760
+
761
+ console.log('');
762
+ console.log('Update complete. config.json was preserved.');
763
+ }
764
+
765
+ // Install Python deps from requirements.txt (preferred) or fall back to the
766
+ // hardcoded list. Idempotent — pip3 install is a no-op when the package is
767
+ // already at the requested version. Playwright also needs the Chromium
768
+ // browser binary; we run `playwright install chromium` after the pip install.
769
+ function installPythonDeps() {
770
+ const reqPath = path.join(PKG_ROOT, 'requirements.txt');
771
+ const args = fs.existsSync(reqPath)
772
+ ? ['-r', reqPath, '-q']
773
+ : ['-q', 'playwright'];
774
+ // Install into the SAME interpreter the MCP server runs (S4L_PYTHON =
775
+ // Homebrew python), NOT bare pip3 which on macOS targets the Xcode CLT system
776
+ // python — deps installed there are invisible to the scripts at runtime.
777
+ // pipInstall() also gates --break-system-packages on pip>=23 so it doesn't
778
+ // hard-fail against the ancient system pip.
779
+ const pythonBin = findPythonBin();
780
+ console.log(` installing Python deps (playwright, ...) into ${pythonBin}`);
781
+ const r = pipInstall(pythonBin, args);
782
+ if (r.status !== 0) {
783
+ console.warn(' WARNING: pip install failed — run manually:');
784
+ console.warn(` ${pythonBin} -m pip install ${args.join(' ')} --break-system-packages`);
785
+ return;
786
+ }
787
+ // Playwright needs its browser binary downloaded separately. Chromium
788
+ // is the only engine the repo uses today; skip Firefox/WebKit.
789
+ console.log(' installing Playwright Chromium binary (one-time, ~150MB)...');
790
+ const pw = spawnSync(pythonBin, ['-m', 'playwright', 'install', 'chromium'], { stdio: 'inherit' });
791
+ if (pw.status !== 0) {
792
+ console.warn(' WARNING: playwright install chromium failed — run manually:');
793
+ console.warn(` ${pythonBin} -m playwright install chromium`);
794
+ }
795
+ }
796
+
797
+ // Set up the social-autoposter MCP server (the X/Twitter draft/autopilot/stats
798
+ // surface for Claude Desktop + Claude Code). The package ships a prebuilt
799
+ // mcp/dist/, so we only install the runtime deps (@modelcontextprotocol/sdk +
800
+ // zod) and register the server into both clients. REPO_DIR auto-resolves to
801
+ // ~/social-autoposter (mcp/../..) so no env wiring is needed beyond what
802
+ // install.mjs pins. Idempotent; safe on both init and update.
803
+ function installMcp() {
804
+ const mcpDest = path.join(DEST, 'mcp');
805
+ if (!fs.existsSync(path.join(mcpDest, 'package.json'))) {
806
+ console.warn(' WARNING: mcp/ missing from install — skipping MCP setup');
807
+ return;
808
+ }
809
+ // Stamp the REAL shipped version (this npm package's version) into the MCP so
810
+ // it can report itself accurately at runtime. The top-level package.json is
811
+ // NOT copied into the install, so without this the MCP can't see its true
812
+ // version. mcp/src/version.ts reads dist/version.json first. Refreshed on
813
+ // every init/update.
814
+ try {
815
+ const pkgVersion = require('../package.json').version;
816
+ const distDir = path.join(mcpDest, 'dist');
817
+ fs.mkdirSync(distDir, { recursive: true });
818
+ fs.writeFileSync(
819
+ path.join(distDir, 'version.json'),
820
+ JSON.stringify({ version: pkgVersion, installedAt: new Date().toISOString() }, null, 2)
821
+ );
822
+ console.log(' stamped MCP version', pkgVersion);
823
+ } catch (e) {
824
+ console.warn(' WARNING: could not stamp MCP version:', e && e.message);
825
+ }
826
+ console.log(' installing MCP runtime deps (npm install --omit=dev in mcp/)');
827
+ const npmRes = spawnSync('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], {
828
+ cwd: mcpDest,
829
+ stdio: 'inherit',
830
+ });
831
+ if (npmRes.status !== 0) {
832
+ console.warn(' WARNING: npm install in mcp/ failed — run manually:');
833
+ console.warn(' (cd ~/social-autoposter/mcp && npm install --omit=dev)');
834
+ return;
835
+ }
836
+ console.log(' registering social-autoposter MCP with Claude Desktop + Claude Code');
837
+ const reg = spawnSync('node', ['install.mjs'], { cwd: mcpDest, stdio: 'inherit' });
838
+ if (reg.status !== 0) {
839
+ console.warn(' WARNING: MCP client registration failed — run manually:');
840
+ console.warn(' (cd ~/social-autoposter/mcp && node install.mjs)');
841
+ }
842
+ }
843
+
844
+ // Sweep the legacy candidate-style sidecar JSON + lock file off every install.
845
+ // The taxonomy lives in Postgres `engagement_styles_registry` now (single
846
+ // source of truth for all installs, no per-machine JSON drift); see
847
+ // scripts/migrate_engagement_styles_to_db.py for the cutover. We keep this
848
+ // helper around for a release or two so existing installs auto-clean the
849
+ // dead files on next `init` / `update`, then it can go.
850
+ function removeLegacyEngagementStylesSidecar() {
851
+ const targets = [
852
+ path.join(DEST, 'scripts', 'engagement_styles_extra.json'),
853
+ path.join(DEST, 'scripts', 'engagement_styles_extra.json.lock'),
854
+ ];
855
+ for (const p of targets) {
856
+ if (fs.existsSync(p)) {
857
+ try {
858
+ fs.rmSync(p, { force: true });
859
+ console.log(` removed legacy ${path.relative(DEST, p)} (registry is now in Postgres)`);
860
+ } catch (e) {
861
+ console.warn(` WARNING: could not remove ${p}: ${e.message}`);
862
+ }
863
+ }
864
+ }
865
+ }
866
+
867
+ // `doctor` is a structured diagnostic engine shared with MCP onboarding.
868
+ // Human-readable output remains the default; --json gives setup tools and CI a
869
+ // stable machine-readable report. --phase pre_connect treats the not-yet-created
870
+ // X session/cookie artifacts as expected, while full verifies the completed
871
+ // environment after connect_x.
872
+ function doctor() {
873
+ const args = process.argv.slice(3);
874
+ const json = args.includes('--json');
875
+ const phaseArg = args.find((arg) => arg.startsWith('--phase='));
876
+ const phaseIndex = args.indexOf('--phase');
877
+ const phase =
878
+ (phaseArg && phaseArg.slice('--phase='.length)) ||
879
+ (phaseIndex >= 0 ? args[phaseIndex + 1] : null) ||
880
+ 'full';
881
+ if (!['pre_connect', 'full'].includes(phase)) {
882
+ console.error("doctor: --phase must be 'pre_connect' or 'full'");
883
+ process.exit(2);
884
+ }
885
+ const report = runDoctorSync({
886
+ phase,
887
+ home: HOME,
888
+ repoDir: fs.existsSync(DEST) ? DEST : PKG_ROOT,
889
+ python: findPythonBin(),
890
+ });
891
+ // Doctor runs are durable even when invoked directly from the CLI. MCP uses
892
+ // this same ledger, so a later onboarding session can show the historical run.
893
+ recordDoctorReport(report);
894
+ console.log(json ? JSON.stringify(report, null, 2) : formatDoctorReport(report));
895
+ if (!report.ok) process.exitCode = 1;
896
+ }
897
+
898
+ // Provision the owned Python/Chromium runtime from the terminal. This is the
899
+ // panel-free path: it runs the EXACT same provisioning logic the panel's
900
+ // "Install runtime" button and the install_runtime MCP tool use (mcp/src/
901
+ // runtime.ts -> dist/runtime.js), via the thin ESM wrapper mcp/install-runtime.mjs.
902
+ // Use it when the UI panel can't render (Claude Code/Cowork), on a bare VM, or
903
+ // when an agent wants to install head-less. Idempotent: re-running repairs.
904
+ function installRuntime() {
905
+ const wrapper = path.join(__dirname, '..', 'mcp', 'install-runtime.mjs');
906
+ if (!fs.existsSync(wrapper)) {
907
+ console.error(`Cannot find ${wrapper}. Re-run \`npx social-autoposter update\` to repair the install.`);
908
+ process.exit(1);
909
+ }
910
+ // process.execPath is the Node already running this CLI, so we reuse it
911
+ // rather than hunting for a node on PATH.
912
+ const res = spawnSync(process.execPath, [wrapper], { stdio: 'inherit' });
913
+ process.exit(res.status == null ? 1 : res.status);
914
+ }
915
+
916
+ // Wipe a social-autoposter install back to factory-fresh (test machines /
917
+ // uninstall). Shells out to the bundled scripts/reset-test-machine.sh, which is
918
+ // the single source of truth: it removes the owned state dir (~/.social-
919
+ // autoposter-mcp), packaged Chrome profiles + imported cookies, the browser-
920
+ // harness clone/CLI/server.py, the global npm package, and the MCP registration.
921
+ // DEFAULT IS A DRY RUN (prints what WOULD be removed); pass --yes to apply, and
922
+ // --deep to also remove the shared uv toolchain + Chromium cache. The script's
923
+ // one standard path quits Claude Desktop, wipes, settles, then relaunches
924
+ // Claude Desktop fresh. Forwarding to the shell script keeps npm and .mcpb
925
+ // installs behaving identically.
926
+ function reset() {
927
+ const script = path.join(PKG_ROOT, 'scripts', 'reset-test-machine.sh');
928
+ if (!fs.existsSync(script)) {
929
+ console.error(`Cannot find ${script}. Re-run \`npx social-autoposter update\` to repair the install.`);
930
+ process.exit(1);
931
+ }
932
+ // Forward everything after `reset` (e.g. --yes, --deep) straight to the script.
933
+ const res = spawnSync('bash', [script, ...process.argv.slice(3)], { stdio: 'inherit' });
934
+ process.exit(res.status == null ? 1 : res.status);
935
+ }
936
+
937
+ const cmd = process.argv[2];
938
+ if (cmd === 'init') {
939
+ init();
940
+ } else if (cmd === 'update') {
941
+ update();
942
+ } else if (cmd === 'doctor') {
943
+ doctor();
944
+ } else if (cmd === 'install-runtime') {
945
+ installRuntime();
946
+ } else if (cmd === 'reset' || cmd === 'uninstall') {
947
+ reset();
948
+ } else if (cmd === 'export-cookies') {
949
+ // Forward to cookie-helper with 'export' + remaining args
950
+ process.argv = [process.argv[0], process.argv[1], 'export', ...process.argv.slice(3)];
951
+ require('./cookie-helper.js');
952
+ } else if (cmd === 'import-cookies') {
953
+ // Forward to cookie-helper with 'import' + remaining args
954
+ process.argv = [process.argv[0], process.argv[1], 'import', ...process.argv.slice(3)];
955
+ require('./cookie-helper.js');
956
+ } else if (!cmd) {
957
+ // The dashboard server (bin/server.js) is a local-only operator tool and is
958
+ // NOT shipped in the published package (it talks directly to Postgres). When
959
+ // it's absent, fall back to usage help instead of crashing on a missing require.
960
+ if (fs.existsSync(path.join(__dirname, 'server.js'))) {
961
+ require('./server.js');
962
+ } else {
963
+ console.log('social-autoposter — automated social posting for Claude agents');
964
+ console.log('');
965
+ console.log('The local dashboard is not part of the published package.');
966
+ console.log('Run `npx social-autoposter init` to set up, then drive it from your Claude agent.');
967
+ }
968
+ } else {
969
+ console.log('social-autoposter — automated social posting for Claude agents');
970
+ console.log('');
971
+ console.log('Usage:');
972
+ console.log(' npx social-autoposter open the dashboard');
973
+ console.log(' npx social-autoposter init first-time setup');
974
+ console.log(' npx social-autoposter update update scripts, preserve config');
975
+ console.log(' npx social-autoposter doctor [--json] [--phase pre_connect|full]');
976
+ console.log(' npx social-autoposter install-runtime provision owned Python + Chromium (panel-free)');
977
+ console.log(' npx social-autoposter export-cookies [dir] export browser cookies');
978
+ console.log(' npx social-autoposter import-cookies [dir] import browser cookies');
979
+ console.log(' npx social-autoposter reset [--yes] [--deep] uninstall/wipe the install (dry-run unless --yes)');
980
+ }