@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/package.json ADDED
@@ -0,0 +1,160 @@
1
+ {
2
+ "name": "@m13v/s4l",
3
+ "version": "1.6.197-rc.10",
4
+ "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
+ "bin": {
6
+ "social-autoposter": "bin/cli.js",
7
+ "s4l": "bin/cli.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/server.js",
11
+ "dev": "node --watch bin/server.js",
12
+ "release:mcpb": "bash scripts/release-mcpb.sh",
13
+ "test": "node test/platform.test.js && node test/launchd-golden.test.js && node test/systemd-golden.test.js && node mcp/scripts/test_onboarding_contract.mjs && node mcp/scripts/test_onboarding_ledger.mjs"
14
+ },
15
+ "files": [
16
+ "bin/",
17
+ "!bin/server.js",
18
+ "!bin/auth.js",
19
+ "scripts/*.py",
20
+ "!scripts/db_direct.py",
21
+ "!scripts/backfill_real_clicks.py",
22
+ "!scripts/historical_engagement.py",
23
+ "!scripts/style_length_report.py",
24
+ "!scripts/install_lane_monitor.py",
25
+ "!scripts/li_discover_insert.py",
26
+ "!scripts/_dm_record_sent.sh",
27
+ "!scripts/send_batch_dms.sh",
28
+ "!scripts/mint_podlog_subpage_*.py",
29
+ "!scripts/tmp_*.py",
30
+ "!scripts/insert_post*.py",
31
+ "!scripts/_insert_post_*.py",
32
+ "!scripts/_log_cyrano_*.py",
33
+ "!scripts/_redditlink_finalize.py",
34
+ "!scripts/_seo_lane_roi.py",
35
+ "!scripts/check_improve_runs.py",
36
+ "!scripts/classify_all_dms.py",
37
+ "!scripts/migrate_*.py",
38
+ "!scripts/_gsc_roi_query.py",
39
+ "!scripts/batch_send_dms.py",
40
+ "!scripts/finalize_post_*.py",
41
+ "!scripts/insert_li_notifs.py",
42
+ "!scripts/li_notif_scan_process.py",
43
+ "!scripts/phase_d_edit.py",
44
+ "!scripts/phase_d_new_comments.py",
45
+ "!scripts/realign_sequences.py",
46
+ "!scripts/scratch_*.py",
47
+ "!scripts/seed_dashboard_users.py",
48
+ "!scripts/send_dashboard_invite.py",
49
+ "!scripts/_dm_icp_batch.py",
50
+ "!scripts/_li_discover_pending.py",
51
+ "!scripts/_phaseA_build_envelope.py",
52
+ "!scripts/_phaseA_select_5AI7GO.py",
53
+ "!scripts/_scan_aggregate.py",
54
+ "!scripts/_scan_timeline.py",
55
+ "!scripts/_serp_report.py",
56
+ "!scripts/_serp_vs_gsc_report.py",
57
+ "!scripts/_test_since_hook.py",
58
+ "!scripts/add_deploy_metadata.py",
59
+ "!scripts/add_nightowl_to_config.py",
60
+ "!scripts/amplitude_user_lookup.py",
61
+ "!scripts/audit_signup_wiring.py",
62
+ "!scripts/backfill_aggregate_stats.py",
63
+ "!scripts/backfill_claude_session_subagents.py",
64
+ "!scripts/backfill_crossroute_attribution.py",
65
+ "!scripts/backfill_ensure_dms.py",
66
+ "!scripts/backfill_icp_precheck.py",
67
+ "!scripts/backfill_linkedin_activity_urns.py",
68
+ "!scripts/backfill_mk0r_get_started.py",
69
+ "!scripts/backfill_run_monitor.py",
70
+ "!scripts/backfill_seo_authors.py",
71
+ "!scripts/backfill_seo_engagement.py",
72
+ "!scripts/backfill_target_project.py",
73
+ "!scripts/blog_refactor_single_route.py",
74
+ "!scripts/browser_watch.py",
75
+ "!scripts/check_analytics_wiring.py",
76
+ "!scripts/check_backfill_replied.py",
77
+ "!scripts/check_contrast.py",
78
+ "!scripts/check_deploy_wiring.py",
79
+ "!scripts/check_layout_wiring.py",
80
+ "!scripts/check_link_rules.py",
81
+ "!scripts/check_pep604_annotations.py",
82
+ "!scripts/cleanup_moltbook_dupes_16060.py",
83
+ "!scripts/cohort_score_distribution.py",
84
+ "!scripts/daily_stats_email.py",
85
+ "!scripts/delete_twitter_posts.py",
86
+ "!scripts/dm_helper.py",
87
+ "!scripts/export_cdp_storage_state.py",
88
+ "!scripts/export_kent_handoff.py",
89
+ "!scripts/extract_user_messages_today.py",
90
+ "!scripts/fazm_seo_health.py",
91
+ "!scripts/fix_mdx_light_mode.py",
92
+ "!scripts/fix_style_lengths_20260601.py",
93
+ "!scripts/fix_svg_paragraph_wrap.py",
94
+ "!scripts/ig_collate_transcripts.py",
95
+ "!scripts/ingest_human_seo_replies.py",
96
+ "!scripts/install_lane_digest.py",
97
+ "!scripts/li_process_notifications.py",
98
+ "!scripts/li_process_notifs.py",
99
+ "!scripts/octolens_threads.py",
100
+ "!scripts/octolens_twitter_batch.py",
101
+ "!scripts/octolens_twitter_cdp.py",
102
+ "!scripts/poll_web_chat.py",
103
+ "!scripts/process_li_notifs.py",
104
+ "!scripts/project_deploy_status.py",
105
+ "!scripts/regenerate_ig_plists.py",
106
+ "!scripts/send_comment_replies.py",
107
+ "!scripts/seo_health_all_projects.py",
108
+ "!scripts/snapshot_style_targets.py",
109
+ "!scripts/socialcrawl.py",
110
+ "!scripts/sweep_guide_chrome.py",
111
+ "!scripts/sync_web_chat_config.py",
112
+ "!scripts/test_own_reply_dedup.py",
113
+ "!scripts/twitter_compose_dm.py",
114
+ "scripts/*.sh",
115
+ "!scripts/_dm_icp_batch.sh",
116
+ "config.example.json",
117
+ "requirements.txt",
118
+ "SKILL.md",
119
+ "skill/*.sh",
120
+ "skill/lib/*.sh",
121
+ "setup/SKILL.md",
122
+ "browser-agent-configs/",
123
+ "mcp-servers/",
124
+ "mcp/dist/",
125
+ "!mcp/dist/pipeline.tgz",
126
+ "mcp/shared/",
127
+ "mcp/menubar/",
128
+ "mcp/package.json",
129
+ "mcp/manifest.json",
130
+ "mcp/install.mjs",
131
+ "mcp/install-runtime.mjs",
132
+ "!**/__pycache__/**",
133
+ "!**/*.pyc"
134
+ ],
135
+ "keywords": [
136
+ "social-media",
137
+ "automation",
138
+ "claude",
139
+ "claude-code",
140
+ "ai-agent",
141
+ "reddit",
142
+ "twitter",
143
+ "linkedin"
144
+ ],
145
+ "author": "Matthew Diakonov",
146
+ "license": "MIT",
147
+ "repository": {
148
+ "type": "git",
149
+ "url": "git+https://github.com/m13v/social-autoposter.git"
150
+ },
151
+ "homepage": "https://github.com/m13v/social-autoposter",
152
+ "engines": {
153
+ "node": ">=16"
154
+ },
155
+ "dependencies": {
156
+ "firebase-admin": "^13.8.0",
157
+ "pg": "^8.20.0",
158
+ "ws": "^8.0.0"
159
+ }
160
+ }
@@ -0,0 +1,20 @@
1
+ # social-autoposter Python deps.
2
+ # Installed by bin/cli.js on init/update into the SAPS_PYTHON interpreter
3
+ # (Homebrew python), via `<saps_python> -m pip install -r requirements.txt`.
4
+ # Playwright browser binaries are downloaded separately (same interpreter:
5
+ # `<saps_python> -m playwright install chromium`).
6
+
7
+ playwright
8
+ # CDP client used by scripts/restore_twitter_session.py and the cookie-grab/
9
+ # CDP-driven helpers. Required on AppMaker VMs for the post-substitution
10
+ # auto-restore flow (bootstrap-vm, restore_twitter_session.py, CDP inject).
11
+ websocket-client
12
+ # AES-CBC decrypt of Chromium cookie values for scripts/copy_browser_cookies.py
13
+ # (the connect_x auto-import that copies the user's x.com session from their
14
+ # everyday browser into the autoposter's managed Chrome). Without this, the
15
+ # MCP setup connect_x step cannot auto-import and falls back to manual login.
16
+ cryptography
17
+ # Error reporting for the Python posting pipeline -> Sentry (org mediar-n5),
18
+ # initialized best-effort by scripts/sentry_init.py via scripts/http_api.py.
19
+ # Safe no-op when absent or when no DSN is configured.
20
+ sentry-sdk
@@ -0,0 +1,58 @@
1
+ import os, re, json, subprocess, glob
2
+ REPO = os.path.expanduser("~/social-autoposter"); os.chdir(REPO)
3
+ all_py = {os.path.basename(p) for p in glob.glob("scripts/*.py")}
4
+
5
+ entry_surfaces = glob.glob("skill/*.sh") + glob.glob("skill/lib/*.sh")
6
+ entry_surfaces += ["SKILL.md", "setup/SKILL.md", "bin/cli.js"]
7
+ entry_surfaces += glob.glob("mcp/dist/*.js") + glob.glob("mcp/*.mjs")
8
+
9
+ ref_re = re.compile(r"scripts/([A-Za-z0-9_]+)\.py")
10
+ def refs_in_text(txt):
11
+ return {m+".py" for m in ref_re.findall(txt) if (m+".py") in all_py}
12
+
13
+ entries, surface_hits = set(), {}
14
+ for s in entry_surfaces:
15
+ if not os.path.exists(s): continue
16
+ txt = open(s, encoding="utf-8", errors="ignore").read()
17
+ # strip // comments for js, # comments for md is harder; keep simple: count all refs but mark cli.js comment lines
18
+ for fn in refs_in_text(txt):
19
+ entries.add(fn); surface_hits.setdefault(fn,set()).add(s)
20
+
21
+ imp_res = [re.compile(r"^\s*import\s+([A-Za-z0-9_]+)", re.M),
22
+ re.compile(r"^\s*from\s+([A-Za-z0-9_]+)\s+import", re.M),
23
+ re.compile(r"^\s*from\s+scripts\s+import\s+([A-Za-z0-9_,\s]+)", re.M),
24
+ re.compile(r"^\s*from\s+scripts\.([A-Za-z0-9_]+)\s+import", re.M),
25
+ re.compile(r"^\s*import\s+scripts\.([A-Za-z0-9_]+)", re.M)]
26
+ def expand(fn):
27
+ path=os.path.join("scripts",fn)
28
+ if not os.path.exists(path): return set()
29
+ txt=open(path,encoding="utf-8",errors="ignore").read()
30
+ found=set()
31
+ for rx in imp_res:
32
+ for g in rx.findall(txt):
33
+ for name in re.split(r"[,\s]+",g):
34
+ name=name.strip()
35
+ if name and (name+".py") in all_py: found.add(name+".py")
36
+ found |= refs_in_text(txt) # NEW: intra-python subprocess scripts/X.py refs
37
+ # follow symlink targets too (update_stats.py -> stats.py)
38
+ if os.path.islink(path):
39
+ tgt=os.path.basename(os.readlink(path))
40
+ if tgt in all_py: found.add(tgt)
41
+ return found
42
+
43
+ closure=set(entries); stack=list(entries)
44
+ while stack:
45
+ for d in expand(stack.pop()):
46
+ if d not in closure: closure.add(d); stack.append(d)
47
+
48
+ out=subprocess.run(["npm","pack","--dry-run","--json"],capture_output=True,text=True)
49
+ shipped=sorted(os.path.basename(f["path"]) for f in json.loads(out.stdout)[0]["files"] if f["path"].startswith("scripts/") and f["path"].endswith(".py"))
50
+ drop=sorted(set(shipped)-closure); keep=sorted(set(shipped)&closure)
51
+ print("entries:",len(entries),"| closure:",len(closure),"| shipped:",len(shipped))
52
+ print(f"\n=== KEEP (shipped & needed): {len(keep)}")
53
+ print(f"=== DROP (shipped but unreferenced anywhere consumer): {len(drop)}")
54
+ for d in drop:
55
+ if d=="_compute_allowlist.py": continue
56
+ print(" -",d)
57
+ open("/tmp/keep.txt","w").write("\n".join(k for k in keep if k!="_compute_allowlist.py"))
58
+ open("/tmp/drop.txt","w").write("\n".join(d for d in drop if d!="_compute_allowlist.py"))
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env python3
2
+ """Scratch driver: read JSON list of {post_id, session, text} from argv[1] and
3
+ run link_edit_helper mark-edited + dm_short_links backfill-post for each.
4
+ Gitignored scratch helper for the reddit link-edit run."""
5
+ import json, subprocess, sys, os
6
+
7
+ HERE = os.path.dirname(os.path.abspath(__file__))
8
+ items = json.load(open(sys.argv[1]))
9
+ for it in items:
10
+ pid = str(it["post_id"]); sess = it["session"]; text = it["text"]
11
+ src = it.get("source", "plain_url_ab_skip")
12
+ r1 = subprocess.run([sys.executable, os.path.join(HERE, "link_edit_helper.py"),
13
+ "mark-edited", "--post-id", pid, "--content", text, "--source", src],
14
+ capture_output=True, text=True)
15
+ r2 = subprocess.run([sys.executable, os.path.join(HERE, "dm_short_links.py"),
16
+ "backfill-post", "--minted-session", sess, "--post-id", pid],
17
+ capture_output=True, text=True)
18
+ bf = (r2.stdout or "").strip().splitlines()[-1:] or [""]
19
+ print(f"post {pid}: mark_edited_rc={r1.returncode} backfill={bf[0]}"
20
+ + (f" ERR1={r1.stderr.strip()}" if r1.returncode else ""))
@@ -0,0 +1,9 @@
1
+ import sys,json
2
+ d=json.load(sys.stdin)
3
+ print("result_count:",d.get("result_count"),"error:",d.get("error"))
4
+ for r in d.get("results",[])[:5]:
5
+ name=r.get("author_name")
6
+ hl=(r.get("author_headline") or "")[:75]
7
+ t=(r.get("post_text") or "")[:170].replace("\n"," ")
8
+ print(" - %s | %s | age=%sh rx=%s c=%s vs=%s" % (name,hl,r.get("age_hours"),r.get("reactions"),r.get("comments"),r.get("velocity_score")))
9
+ print(" "+t)
@@ -0,0 +1,76 @@
1
+ import json, re
2
+
3
+ items = json.load(open("/tmp/li_actionable.json"))
4
+ cids = set(l.strip() for l in open("/tmp/li_cids.txt") if l.strip())
5
+ pairs = set(l.strip() for l in open("/tmp/li_pairs.txt") if l.strip())
6
+ posts = []
7
+ for l in open("/tmp/li_posts.txt"):
8
+ l=l.strip()
9
+ if not l: continue
10
+ pid, _, url = l.partition("|")
11
+ posts.append((pid, url))
12
+
13
+ EXCLUDED = {"louis030195","louis3195"}
14
+ OWN = {"matthew diakonov","m13v"}
15
+
16
+ def parse_urn(urn):
17
+ # urn:li:comment:(NS:PARENT,COMMENT)
18
+ m = re.match(r"urn:li:comment:\((\w+):(\d+),(\d+)\)", urn or "")
19
+ if not m: return (None,None,None)
20
+ return m.group(1), m.group(2), m.group(3)
21
+
22
+ def find_post_id(parent_id):
23
+ for pid, url in posts:
24
+ if parent_id and parent_id in url:
25
+ return pid
26
+ return None
27
+
28
+ def author_engaged(author, parent_id):
29
+ # any engaged pair with same author AND url containing parent_id
30
+ al = author.strip().lower()
31
+ for p in pairs:
32
+ a, _, url = p.partition("|||")
33
+ if a.strip().lower()==al and parent_id and parent_id in url:
34
+ return True
35
+ return False
36
+
37
+ seen_batch = set()
38
+ plan = []
39
+ counts = dict(new=0, already=0, engaged=0, excluded=0, own=0, nourn=0, dup_batch=0)
40
+
41
+ for it in items:
42
+ urn = it.get("comment_urn")
43
+ author = (it.get("author") or "").strip()
44
+ ns, parent_id, comment_id = parse_urn(urn)
45
+ rec = dict(it, ns=ns, parent_id=parent_id, comment_id=comment_id, decision=None, post_id=None)
46
+
47
+ if not urn or not parent_id:
48
+ rec["decision"]="skip:no_comment_urn"; counts["nourn"]+=1; plan.append(rec); continue
49
+ if urn in seen_batch:
50
+ rec["decision"]="skip:dup_in_batch"; counts["dup_batch"]+=1; plan.append(rec); continue
51
+ seen_batch.add(urn)
52
+ if urn in cids:
53
+ rec["decision"]="skip:already_tracked"; counts["already"]+=1; plan.append(rec); continue
54
+ al = author.lower()
55
+ if al in OWN or any(al==e for e in EXCLUDED):
56
+ rec["decision"]="skip:own_or_excluded"
57
+ if al in OWN: counts["own"]+=1
58
+ else: counts["excluded"]+=1
59
+ plan.append(rec); continue
60
+ if author_engaged(author, parent_id):
61
+ rec["decision"]="skip:author_already_engaged"; counts["engaged"]+=1; plan.append(rec); continue
62
+ pid = find_post_id(parent_id)
63
+ rec["post_id"]=pid
64
+ rec["decision"]="insert" if pid else "create_post+insert"
65
+ counts["new"]+=1
66
+ plan.append(rec)
67
+
68
+ json.dump(plan, open("/tmp/li_plan.json","w"), indent=2)
69
+ print("COUNTS:", counts)
70
+ print("TOTAL inspected:", len(items))
71
+ print()
72
+ for r in plan:
73
+ if r["decision"].startswith("skip"): continue
74
+ print(f"[{r['decision']}] {r['author']} | ns={r['ns']} parent={r['parent_id']} post_id={r['post_id']}")
75
+ print(f" urn: {r['comment_urn']}")
76
+ print(f" snip: {r['snippet'][:120]}")
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env python3
2
+ import json, re, subprocess, os, sys
3
+
4
+ REPO = os.path.expanduser("~/social-autoposter")
5
+ LID = os.path.join(REPO, "scripts", "li_discovery.py")
6
+
7
+ EXCLUDED_AUTHORS = {"louis030195", "louis3195"}
8
+ OWN = {"matthew diakonov", "m13v"}
9
+
10
+ def run(args):
11
+ r = subprocess.run([sys.executable, LID] + args, capture_output=True, text=True)
12
+ return (r.stdout or "").strip(), (r.stderr or "").strip(), r.returncode
13
+
14
+ # one context dump
15
+ ctx_out, ctx_err, rc = run(["context"])
16
+ ctx = json.loads(ctx_out) if ctx_out else {}
17
+ existing = set(ctx.get("existing_comment_ids") or [])
18
+ engaged_pairs = ctx.get("engaged_pairs") or []
19
+ posts = ctx.get("posts") or []
20
+
21
+ PARENT_RE = re.compile(r"urn:li:(?:activity|ugcPost|share):(\d+)")
22
+
23
+ # build parent_id -> post_id map
24
+ parent_to_post = {}
25
+ for p in posts:
26
+ u = p.get("our_url") or ""
27
+ m = PARENT_RE.search(u)
28
+ if m:
29
+ parent_to_post.setdefault(m.group(1), p["id"])
30
+
31
+ # build engaged set (author_lower, parent_id)
32
+ engaged_set = set()
33
+ for pair in engaged_pairs:
34
+ if "|||" not in pair:
35
+ continue
36
+ author, url = pair.split("|||", 1)
37
+ m = PARENT_RE.search(url)
38
+ if m:
39
+ engaged_set.add((author.strip().lower(), m.group(1)))
40
+
41
+ CU_RE = re.compile(r"\((?:activity|ugcPost|share):(\d+),(\d+)\)")
42
+
43
+ data = json.load(open("/tmp/li_notifs.json"))
44
+
45
+ counts = dict(scanned=len(data), new=0, already=0, engaged=0, excluded=0, own=0, no_urn=0)
46
+ new_items = []
47
+
48
+ def proj_for(snippet):
49
+ s = (snippet or "").lower()
50
+ # our niche is claude code / ai agents -> fazm flagship
51
+ if any(k in s for k in ["claude code", "claude.md", "agent", "context window", "mcp", "subagent", "harness", "codex", "anthropic", "llm", "ai "]):
52
+ return "fazm"
53
+ return "general"
54
+
55
+ for it in data:
56
+ cu = it.get("comment_urn")
57
+ author = (it.get("author") or "").strip()
58
+ if not cu:
59
+ counts["no_urn"] += 1
60
+ continue
61
+ m = CU_RE.search(cu)
62
+ if not m:
63
+ counts["no_urn"] += 1
64
+ continue
65
+ parent_id = m.group(1)
66
+ al = author.lower()
67
+ # exclusion
68
+ if al in OWN or author in ("unknown",):
69
+ counts["own"] += 1
70
+ continue
71
+ if al in EXCLUDED_AUTHORS or any(x in al for x in EXCLUDED_AUTHORS):
72
+ counts["excluded"] += 1
73
+ continue
74
+ if cu in existing:
75
+ counts["already"] += 1
76
+ continue
77
+ if (al, parent_id) in engaged_set:
78
+ counts["engaged"] += 1
79
+ continue
80
+ # find or create post
81
+ post_id = parent_to_post.get(parent_id)
82
+ if not post_id:
83
+ proj = proj_for(it.get("snippet"))
84
+ out, err, rc = run(["create-post", "--activity-id", parent_id, "--project", proj, "--author", author])
85
+ post_id = out.strip().splitlines()[-1] if out.strip() else ""
86
+ if not post_id:
87
+ print(f" [create-post FAILED parent={parent_id}] err={err}", file=sys.stderr)
88
+ continue
89
+ parent_to_post[parent_id] = post_id
90
+ # insert reply
91
+ out, err, rc = run([
92
+ "insert-reply", "--post-id", str(post_id),
93
+ "--comment-urn", cu, "--author", author,
94
+ "--content", (it.get("snippet") or "")[:3000],
95
+ "--href", it.get("href") or "",
96
+ ])
97
+ res = out.strip().splitlines()[-1] if out.strip() else ""
98
+ if res == "duplicate":
99
+ counts["already"] += 1
100
+ elif res.startswith("gated"):
101
+ counts["engaged"] += 1 # gated by blocklist/velocity, not actionable
102
+ print(f" [gated] {author} parent={parent_id} -> {res}", file=sys.stderr)
103
+ elif res:
104
+ counts["new"] += 1
105
+ new_items.append((res, author, parent_id, cu))
106
+ # mark as existing to dedup within this run
107
+ existing.add(cu)
108
+ engaged_set.add((al, parent_id))
109
+ else:
110
+ print(f" [insert FAILED] {author} parent={parent_id} err={err}", file=sys.stderr)
111
+
112
+ print("\n=== NEW REPLIES INSERTED ===")
113
+ for rid, author, parent, cu in new_items:
114
+ print(f" reply_id={rid} author={author} parent={parent}")
115
+
116
+ print("\n=== SUMMARY ===")
117
+ print(f"New replies discovered: {counts['new']}")
118
+ print(f"Already tracked: {counts['already']}")
119
+ print(f"Author already engaged thread: {counts['engaged']}")
120
+ print(f"Excluded: {counts['excluded']}")
121
+ print(f"Own account: {counts['own']}")
122
+ print(f"No comment URN: {counts['no_urn']}")
123
+ print(f"Total scanned: {counts['scanned']}")
124
+
125
+ excl_total = counts["excluded"] + counts["own"]
126
+ print(f"\nLINKEDIN_SCAN_SUMMARY: scanned={counts['scanned']} new={counts['new']} already={counts['already']} excluded={excl_total} unmatched={counts['no_urn']}")
@@ -0,0 +1,60 @@
1
+ import json, os, sys, time, subprocess, signal
2
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
3
+
4
+ LOCK = os.path.expanduser("~/.claude/twitter-browser-lock.json")
5
+ os.makedirs(os.path.dirname(LOCK), exist_ok=True)
6
+
7
+ def write_lock(pid, role):
8
+ with open(LOCK, "w") as f:
9
+ json.dump({"session_id": f"python:{pid}", "timestamp": int(time.time()), "role": role}, f)
10
+
11
+ def clean():
12
+ try: os.remove(LOCK)
13
+ except OSError: pass
14
+
15
+ # ---- TEST 1: poster preempts a LIVE scan holder ----
16
+ clean()
17
+ victim = subprocess.Popen(["sleep", "120"])
18
+ write_lock(victim.pid, "scan")
19
+ os.environ["SAPS_LOCK_ROLE"] = "post"
20
+ import importlib, twitter_browser
21
+ importlib.reload(twitter_browser)
22
+ t0 = time.time()
23
+ twitter_browser._acquire_browser_lock()
24
+ dt = time.time() - t0
25
+ held = json.load(open(LOCK))
26
+ victim_alive = victim.poll() is None
27
+ print(f"TEST1 poster-preempts-scan: took={dt:.1f}s holder_now={held['session_id']} role={held.get('role')} victim_alive={victim_alive}")
28
+ assert held["session_id"] == f"python:{os.getpid()}", "poster should hold lock"
29
+ assert held.get("role") == "post"
30
+ assert not victim_alive, "scan victim should be killed"
31
+ assert dt < 10, "should preempt fast, not wait 45s"
32
+ if victim.poll() is None: victim.kill()
33
+ print("TEST1 PASS")
34
+
35
+ # ---- TEST 2: scanner does NOT preempt a live POST holder (waits then gives up) ----
36
+ clean()
37
+ poster = subprocess.Popen(["sleep", "120"])
38
+ write_lock(poster.pid, "post")
39
+ os.environ["SAPS_LOCK_ROLE"] = "scan"
40
+ twitter_browser.LOCK_WAIT_MAX = 4 # shorten the give-up window for the test
41
+ twitter_browser.LOCK_ROLE = "scan"
42
+ twitter_browser._LOCK_SESSION_ID = f"python:{os.getpid()}"
43
+ twitter_browser._LOCK_INHERITED = False
44
+ import io, contextlib
45
+ t0 = time.time()
46
+ rc = None
47
+ try:
48
+ twitter_browser._acquire_browser_lock()
49
+ rc = "acquired"
50
+ except SystemExit as e:
51
+ rc = f"exit:{e.code}"
52
+ dt = time.time() - t0
53
+ poster_alive = poster.poll() is None
54
+ print(f"TEST2 scanner-vs-post: result={rc} took={dt:.1f}s poster_alive={poster_alive}")
55
+ assert rc == "exit:1", "scanner must NOT take a live post holder; should give up"
56
+ assert poster_alive, "scanner must NOT kill the poster"
57
+ if poster.poll() is None: poster.kill()
58
+ print("TEST2 PASS")
59
+ clean()
60
+ print("ALL PASS")
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Throwaway helper: run dm_conversation.py set-icp-precheck for every project.
3
+
4
+ Usage:
5
+ python3 _run_icp_precheck.py --dm-id 4583 --default-label icp_miss \
6
+ --default-notes "crypto researcher, not target vertical" \
7
+ --override 'fazm=icp_miss=engaged on agentic token cost but no claude-code-wrapper signal' \
8
+ --override 'Agora=icp_miss=crypto researcher not a protocol governance buyer'
9
+
10
+ Any project NOT overridden gets the default label/notes.
11
+ """
12
+ import argparse, subprocess, sys
13
+
14
+ PROJECTS = [
15
+ "fazm", "Terminator", "macOS MCP", "Vipassana", "S4L", "AI Browser Profile",
16
+ "WhatsApp MCP", "macOS Session Replay", "Cyrano", "Assrt", "PieLine", "Clone",
17
+ "mk0r", "fde10x", "claude-meter", "c0nsl", "tenxats", "paperback-expert",
18
+ "studyly", "Mediar", "NightOwl", "Runner", "Agora", "Podlog", "ccmd",
19
+ ]
20
+ SCRIPT = "/Users/matthewdi/social-autoposter/scripts/dm_conversation.py"
21
+
22
+ def main():
23
+ ap = argparse.ArgumentParser()
24
+ ap.add_argument("--dm-id", required=True)
25
+ ap.add_argument("--default-label", default="icp_miss")
26
+ ap.add_argument("--default-notes", default="not target vertical")
27
+ ap.add_argument("--override", action="append", default=[],
28
+ help="PROJECT=LABEL=NOTES")
29
+ args = ap.parse_args()
30
+
31
+ overrides = {}
32
+ for o in args.override:
33
+ parts = o.split("=", 2)
34
+ if len(parts) != 3:
35
+ print("bad override:", o); sys.exit(2)
36
+ overrides[parts[0]] = (parts[1], parts[2])
37
+
38
+ # validate override keys
39
+ for k in overrides:
40
+ if k not in PROJECTS:
41
+ print("UNKNOWN project in override:", k); sys.exit(2)
42
+
43
+ fails = 0
44
+ for p in PROJECTS:
45
+ label, notes = overrides.get(p, (args.default_label, args.default_notes))
46
+ r = subprocess.run(
47
+ ["python3", SCRIPT, "set-icp-precheck", "--dm-id", args.dm_id,
48
+ "--project", p, "--label", label, "--notes", notes],
49
+ capture_output=True, text=True)
50
+ tag = "ok" if r.returncode == 0 else "FAIL"
51
+ if r.returncode != 0:
52
+ fails += 1
53
+ print(f"{tag} {p}={label}", (r.stderr or r.stdout or "").strip()[:120])
54
+ print(f"DONE dm={args.dm_id} fails={fails}")
55
+
56
+ if __name__ == "__main__":
57
+ main()