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