@m13v/s4l 1.6.197-rc.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1314 -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 +497 -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,509 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""GitHub CLI tools for Claude to call via Bash.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
python3 scripts/github_tools.py search "QUERY" [--limit 10]
|
|
6
|
+
python3 scripts/github_tools.py view OWNER/REPO NUMBER
|
|
7
|
+
python3 scripts/github_tools.py already-posted "THREAD_URL"
|
|
8
|
+
python3 scripts/github_tools.py log-post THREAD_URL OUR_URL OUR_TEXT PROJECT THREAD_AUTHOR THREAD_TITLE [--account m13v] [--engagement-style STYLE]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
19
|
+
from http_api import api_get, api_post
|
|
20
|
+
from version import read_version as read_autoposter_version
|
|
21
|
+
try:
|
|
22
|
+
from account_resolver import resolve as _resolve_account
|
|
23
|
+
except Exception:
|
|
24
|
+
def _resolve_account(_platform): # type: ignore[unused-arg]
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _github_account_filter():
|
|
29
|
+
"""Return (sql_fragment, params) for a github our_account scope.
|
|
30
|
+
|
|
31
|
+
Empty tuple of params means no scoping is applied (legacy behavior).
|
|
32
|
+
Used so the same query shape works with and without a configured handle.
|
|
33
|
+
"""
|
|
34
|
+
h = _resolve_account("github")
|
|
35
|
+
if h:
|
|
36
|
+
return (" AND our_account = %s", [h])
|
|
37
|
+
return ("", [])
|
|
38
|
+
|
|
39
|
+
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config.json")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_config():
|
|
43
|
+
with open(CONFIG_PATH) as f:
|
|
44
|
+
return json.load(f)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _excluded_repos_and_authors(config):
|
|
48
|
+
exclusions = config.get("exclusions", {})
|
|
49
|
+
repos = {r.lower() for r in exclusions.get("github_repos", [])}
|
|
50
|
+
authors = {a.lower() for a in exclusions.get("authors", [])}
|
|
51
|
+
return repos, authors
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Auto-blocklist: any owner where >= DYNAMIC_BLOCK_THRESHOLD of our github
|
|
55
|
+
# posts under that owner have been moderated (status='deleted' OR
|
|
56
|
+
# deletion_detect_count > 0) within the last DYNAMIC_BLOCK_WINDOW_DAYS days.
|
|
57
|
+
# One strike = stop posting under that owner. The cost of one extra burned
|
|
58
|
+
# comment is much higher than the cost of skipping a borderline-friendly
|
|
59
|
+
# repo. Tuned 2026-05-01 after the antiwork/gumroad block: deletion of #4677
|
|
60
|
+
# alone should have stopped us before #4915. Tightened 2->1 on 2026-06-04
|
|
61
|
+
# after rausermack22-dotcom content-farm repo burned 2 mk0r comments before
|
|
62
|
+
# the owner hit the threshold.
|
|
63
|
+
DYNAMIC_BLOCK_THRESHOLD = 1
|
|
64
|
+
DYNAMIC_BLOCK_WINDOW_DAYS = 90
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_REPO_GONE_CACHE_PATH = os.path.expanduser(
|
|
68
|
+
"~/social-autoposter/skill/cache/github_repo_state.json"
|
|
69
|
+
)
|
|
70
|
+
_REPO_GONE_TTL_SEC = 24 * 3600 # 24h is plenty: a deleted repo stays deleted
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_repo_gone_cache():
|
|
74
|
+
try:
|
|
75
|
+
with open(_REPO_GONE_CACHE_PATH) as f:
|
|
76
|
+
return json.load(f)
|
|
77
|
+
except Exception:
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _save_repo_gone_cache(cache):
|
|
82
|
+
try:
|
|
83
|
+
os.makedirs(os.path.dirname(_REPO_GONE_CACHE_PATH), exist_ok=True)
|
|
84
|
+
with open(_REPO_GONE_CACHE_PATH, "w") as f:
|
|
85
|
+
json.dump(cache, f)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _fetch_repo_state(owner, repo, _mem={}, _disk={"loaded": False, "data": {}}):
|
|
91
|
+
"""Fetch and cache (gone, has_issues, has_discussions) for owner/repo.
|
|
92
|
+
Two-tier cache (in-process + 24h on-disk JSON). Returns a dict
|
|
93
|
+
{gone: bool, has_issues: bool, has_discussions: bool}."""
|
|
94
|
+
key = f"{owner}/{repo}".lower()
|
|
95
|
+
if key in _mem:
|
|
96
|
+
return _mem[key]
|
|
97
|
+
if not _disk["loaded"]:
|
|
98
|
+
_disk["data"] = _load_repo_gone_cache()
|
|
99
|
+
_disk["loaded"] = True
|
|
100
|
+
entry = _disk["data"].get(key)
|
|
101
|
+
now = int(time.time())
|
|
102
|
+
if (entry and (now - int(entry.get("checked_at", 0))) < _REPO_GONE_TTL_SEC
|
|
103
|
+
and "has_issues" in entry):
|
|
104
|
+
state = {
|
|
105
|
+
"gone": bool(entry.get("gone")),
|
|
106
|
+
"has_issues": bool(entry.get("has_issues", True)),
|
|
107
|
+
"has_discussions": bool(entry.get("has_discussions", True)),
|
|
108
|
+
}
|
|
109
|
+
_mem[key] = state
|
|
110
|
+
return state
|
|
111
|
+
try:
|
|
112
|
+
proc = subprocess.run(
|
|
113
|
+
["gh", "api", f"repos/{owner}/{repo}"],
|
|
114
|
+
capture_output=True, text=True, timeout=20,
|
|
115
|
+
)
|
|
116
|
+
except Exception:
|
|
117
|
+
state = {"gone": False, "has_issues": True, "has_discussions": True}
|
|
118
|
+
_mem[key] = state
|
|
119
|
+
return state
|
|
120
|
+
if proc.returncode == 0:
|
|
121
|
+
try:
|
|
122
|
+
data = json.loads(proc.stdout or "{}")
|
|
123
|
+
except Exception:
|
|
124
|
+
data = {}
|
|
125
|
+
state = {
|
|
126
|
+
"gone": False,
|
|
127
|
+
"has_issues": bool(data.get("has_issues", True)),
|
|
128
|
+
"has_discussions": bool(data.get("has_discussions", True)),
|
|
129
|
+
}
|
|
130
|
+
else:
|
|
131
|
+
err = ((proc.stderr or "") + (proc.stdout or "")).lower()
|
|
132
|
+
gone = ("not found" in err or "http 404" in err)
|
|
133
|
+
state = {"gone": gone, "has_issues": True, "has_discussions": True}
|
|
134
|
+
_mem[key] = state
|
|
135
|
+
_disk["data"][key] = {
|
|
136
|
+
"gone": state["gone"],
|
|
137
|
+
"has_issues": state["has_issues"],
|
|
138
|
+
"has_discussions": state["has_discussions"],
|
|
139
|
+
"checked_at": now,
|
|
140
|
+
}
|
|
141
|
+
_save_repo_gone_cache(_disk["data"])
|
|
142
|
+
return state
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _repo_is_gone(owner, repo):
|
|
146
|
+
"""Back-compat alias. Returns True iff the parent repo 404s. Callers
|
|
147
|
+
that want the broader 'this URL is unreachable for non-moderation
|
|
148
|
+
reasons' should use _post_is_collateral(thread_url) instead."""
|
|
149
|
+
return _fetch_repo_state(owner, repo)["gone"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _post_is_collateral(thread_url):
|
|
153
|
+
"""Returns True iff this thread_url died for a non-moderation reason:
|
|
154
|
+
the whole repo 404'd, OR the repo is alive but the feature this URL
|
|
155
|
+
lived on (issues, discussions) has been disabled by the owner. Both
|
|
156
|
+
cases mean every comment under that URL vanished at once and ours is
|
|
157
|
+
not a targeted strike."""
|
|
158
|
+
if not thread_url:
|
|
159
|
+
return False
|
|
160
|
+
from urllib.parse import urlparse as _urlparse
|
|
161
|
+
parts = _urlparse(thread_url).path.strip("/").split("/")
|
|
162
|
+
if len(parts) < 2 or not parts[0] or not parts[1]:
|
|
163
|
+
return False
|
|
164
|
+
owner, repo = parts[0], parts[1]
|
|
165
|
+
state = _fetch_repo_state(owner, repo)
|
|
166
|
+
if state["gone"]:
|
|
167
|
+
return True
|
|
168
|
+
if len(parts) >= 3:
|
|
169
|
+
if parts[2] == "issues" and not state["has_issues"]:
|
|
170
|
+
return True
|
|
171
|
+
if parts[2] == "discussions" and not state["has_discussions"]:
|
|
172
|
+
return True
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _dynamic_owner_blocklist(threshold=DYNAMIC_BLOCK_THRESHOLD,
|
|
177
|
+
days=DYNAMIC_BLOCK_WINDOW_DAYS):
|
|
178
|
+
"""Return lowercased owner names with >=threshold moderated posts in the
|
|
179
|
+
last `days` days. Posts whose entire parent repo is 404 OR whose host
|
|
180
|
+
feature (Issues/Discussions) has been turned off on the repo are excluded
|
|
181
|
+
from the count: owner restructured the project, not a hostility signal.
|
|
182
|
+
Caller unions with static config exclusions before filtering candidates."""
|
|
183
|
+
# Dynamic owner blocklist is scoped per-account so the @matt_diak
|
|
184
|
+
# autoposter doesn't inherit @m13v_'s strike history (or vice versa).
|
|
185
|
+
# Falls back to unscoped when no handle is configured. The moderation
|
|
186
|
+
# filter (status='deleted' OR deletion_detect_count>0, inside the window)
|
|
187
|
+
# is applied server-side via moderated_within_days; the owner-counting and
|
|
188
|
+
# collateral exclusion stay local.
|
|
189
|
+
query = {"platform": "github", "moderated_within_days": str(int(days))}
|
|
190
|
+
handle = _resolve_account("github")
|
|
191
|
+
if handle:
|
|
192
|
+
query["our_account"] = handle
|
|
193
|
+
try:
|
|
194
|
+
resp = api_get("/api/v1/posts/thread-urls", query=query)
|
|
195
|
+
rows = ((resp or {}).get("data") or {}).get("thread_urls") or []
|
|
196
|
+
except Exception:
|
|
197
|
+
return set()
|
|
198
|
+
from collections import Counter
|
|
199
|
+
from urllib.parse import urlparse
|
|
200
|
+
counts = Counter()
|
|
201
|
+
for url in rows:
|
|
202
|
+
if not url:
|
|
203
|
+
continue
|
|
204
|
+
parts = urlparse(url).path.strip("/").split("/")
|
|
205
|
+
if len(parts) < 2 or not parts[0] or not parts[1]:
|
|
206
|
+
continue
|
|
207
|
+
if _post_is_collateral(url):
|
|
208
|
+
# Repo gone or feature disabled: drop from strike count.
|
|
209
|
+
continue
|
|
210
|
+
counts[parts[0].lower()] += 1
|
|
211
|
+
blocked = {owner for owner, n in counts.items() if n >= threshold}
|
|
212
|
+
if blocked:
|
|
213
|
+
print(
|
|
214
|
+
f"[github_blocklist] threshold={threshold} window_days={days} "
|
|
215
|
+
f"blocked={sorted(blocked)}",
|
|
216
|
+
file=sys.stderr,
|
|
217
|
+
)
|
|
218
|
+
return blocked
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _is_excluded_repo(repo_full, excluded_repos):
|
|
222
|
+
"""repo_full is 'owner/name'. Match if either owner or name or full is in excluded list."""
|
|
223
|
+
if not repo_full:
|
|
224
|
+
return False
|
|
225
|
+
rl = repo_full.lower()
|
|
226
|
+
owner = rl.split("/", 1)[0] if "/" in rl else rl
|
|
227
|
+
name = rl.split("/", 1)[1] if "/" in rl else rl
|
|
228
|
+
return rl in excluded_repos or owner in excluded_repos or name in excluded_repos
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cmd_search(args):
|
|
232
|
+
"""Search GitHub for issues via gh CLI. Filters out excluded repos/authors and already-posted threads."""
|
|
233
|
+
try:
|
|
234
|
+
out = subprocess.check_output(
|
|
235
|
+
["gh", "search", "issues", args.query,
|
|
236
|
+
"--limit", str(args.limit),
|
|
237
|
+
"--state", "open",
|
|
238
|
+
"--sort", "updated",
|
|
239
|
+
"--json", "number,title,repository,author,state,updatedAt,url,body"],
|
|
240
|
+
text=True, timeout=30, stderr=subprocess.STDOUT,
|
|
241
|
+
)
|
|
242
|
+
items = json.loads(out)
|
|
243
|
+
except subprocess.CalledProcessError as e:
|
|
244
|
+
print(json.dumps({"error": "gh_search_failed", "message": (e.output or str(e))[:300]}))
|
|
245
|
+
sys.exit(2)
|
|
246
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError) as e:
|
|
247
|
+
print(json.dumps({"error": "gh_search_failed", "message": str(e)[:300]}))
|
|
248
|
+
sys.exit(2)
|
|
249
|
+
|
|
250
|
+
config = _load_config()
|
|
251
|
+
excluded_repos, excluded_authors = _excluded_repos_and_authors(config)
|
|
252
|
+
|
|
253
|
+
excluded_repos = excluded_repos | _dynamic_owner_blocklist()
|
|
254
|
+
# Per-account dedupe: only filter against threads THIS handle posted in.
|
|
255
|
+
_tu_query = {"platform": "github"}
|
|
256
|
+
_handle = _resolve_account("github")
|
|
257
|
+
if _handle:
|
|
258
|
+
_tu_query["our_account"] = _handle
|
|
259
|
+
_tu_resp = api_get("/api/v1/posts/thread-urls", query=_tu_query)
|
|
260
|
+
already_posted = set(((_tu_resp or {}).get("data") or {}).get("thread_urls") or [])
|
|
261
|
+
|
|
262
|
+
results = []
|
|
263
|
+
for item in items:
|
|
264
|
+
repo = item.get("repository", {}) or {}
|
|
265
|
+
repo_full = repo.get("nameWithOwner") or (
|
|
266
|
+
f"{repo.get('owner', {}).get('login', '')}/{repo.get('name', '')}"
|
|
267
|
+
if repo.get("owner") else ""
|
|
268
|
+
)
|
|
269
|
+
author = (item.get("author") or {}).get("login", "")
|
|
270
|
+
|
|
271
|
+
if _is_excluded_repo(repo_full, excluded_repos):
|
|
272
|
+
continue
|
|
273
|
+
if author.lower() in excluded_authors:
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
url = item.get("url", "")
|
|
277
|
+
already = url in already_posted
|
|
278
|
+
entry = {
|
|
279
|
+
"url": url,
|
|
280
|
+
"title": item.get("title", ""),
|
|
281
|
+
"author": author,
|
|
282
|
+
"repo": repo_full,
|
|
283
|
+
"number": item.get("number"),
|
|
284
|
+
"updated_at": item.get("updatedAt", ""),
|
|
285
|
+
"body_preview": (item.get("body") or ""),
|
|
286
|
+
"already_posted": already,
|
|
287
|
+
}
|
|
288
|
+
if already:
|
|
289
|
+
entry["SKIP"] = ">>> ALREADY POSTED IN THIS THREAD - DO NOT POST AGAIN <<<"
|
|
290
|
+
results.append(entry)
|
|
291
|
+
|
|
292
|
+
print(json.dumps(results, indent=2))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def cmd_view(args):
|
|
296
|
+
"""Fetch issue body and comments via gh CLI. Returns compact JSON."""
|
|
297
|
+
# args.repo is 'owner/repo', args.number is the issue number
|
|
298
|
+
try:
|
|
299
|
+
out = subprocess.check_output(
|
|
300
|
+
["gh", "issue", "view", str(args.number), "-R", args.repo,
|
|
301
|
+
"--json", "title,body,author,state,comments,url"],
|
|
302
|
+
text=True, timeout=30, stderr=subprocess.STDOUT,
|
|
303
|
+
)
|
|
304
|
+
thread = json.loads(out)
|
|
305
|
+
except subprocess.CalledProcessError as e:
|
|
306
|
+
print(json.dumps({"error": "gh_view_failed", "message": (e.output or str(e))[:300]}))
|
|
307
|
+
return
|
|
308
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError) as e:
|
|
309
|
+
print(json.dumps({"error": "gh_view_failed", "message": str(e)[:300]}))
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
comments = []
|
|
313
|
+
for c in (thread.get("comments") or []):
|
|
314
|
+
comments.append({
|
|
315
|
+
"author": (c.get("author") or {}).get("login", ""),
|
|
316
|
+
"body": (c.get("body") or ""),
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
compact = {
|
|
320
|
+
"url": thread.get("url", ""),
|
|
321
|
+
"title": thread.get("title", ""),
|
|
322
|
+
"state": thread.get("state", ""),
|
|
323
|
+
"author": (thread.get("author") or {}).get("login", ""),
|
|
324
|
+
"body": (thread.get("body") or ""),
|
|
325
|
+
"comments": comments,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
text = json.dumps(compact, indent=2)
|
|
329
|
+
print(text)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def cmd_already_posted(args):
|
|
333
|
+
"""Check if we already posted in a GitHub issue thread.
|
|
334
|
+
|
|
335
|
+
Scoped per-account so multi-machine setups don't false-positive on
|
|
336
|
+
each other's posts. Falls back to unscoped when no handle is configured.
|
|
337
|
+
"""
|
|
338
|
+
query = {"platform": "github", "thread_url": args.url}
|
|
339
|
+
handle = _resolve_account("github")
|
|
340
|
+
if handle:
|
|
341
|
+
query["our_account"] = handle
|
|
342
|
+
resp = api_get("/api/v1/posts/lookup", query=query)
|
|
343
|
+
row = ((resp or {}).get("data") or {}).get("post")
|
|
344
|
+
if row:
|
|
345
|
+
print(json.dumps({"already_posted": True, "post_id": row.get("id"),
|
|
346
|
+
"content_preview": row.get("our_content")}))
|
|
347
|
+
else:
|
|
348
|
+
print(json.dumps({"already_posted": False}))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def cmd_log_post(args):
|
|
352
|
+
"""Log a posted GitHub comment to the database.
|
|
353
|
+
|
|
354
|
+
Enforces two dedup rules:
|
|
355
|
+
1. Same comment URL is never logged twice (our_url hard dedup).
|
|
356
|
+
2. Only one post per GitHub issue thread (thread_url hard dedup).
|
|
357
|
+
"""
|
|
358
|
+
# our_url stays globally unique (it's a permalink to a specific comment,
|
|
359
|
+
# and two accounts can't physically produce the same one). thread_url
|
|
360
|
+
# dedup is scoped per-account so two handles can each comment once in
|
|
361
|
+
# the same upstream issue thread.
|
|
362
|
+
handle = _resolve_account("github")
|
|
363
|
+
if args.our_url:
|
|
364
|
+
resp = api_get("/api/v1/posts/lookup",
|
|
365
|
+
query={"platform": "github", "our_url": args.our_url})
|
|
366
|
+
existing = ((resp or {}).get("data") or {}).get("post")
|
|
367
|
+
if existing:
|
|
368
|
+
print(json.dumps({"error": "DUPLICATE_URL", "message": "Already logged this comment URL", "existing_post_id": existing.get("id")}))
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
_tq = {"platform": "github", "thread_url": args.thread_url}
|
|
372
|
+
if handle:
|
|
373
|
+
_tq["our_account"] = handle
|
|
374
|
+
resp = api_get("/api/v1/posts/lookup", query=_tq)
|
|
375
|
+
existing = ((resp or {}).get("data") or {}).get("post")
|
|
376
|
+
if existing:
|
|
377
|
+
print(json.dumps({
|
|
378
|
+
"error": "DUPLICATE_THREAD",
|
|
379
|
+
"message": "Already posted in this thread",
|
|
380
|
+
"existing_post_id": existing.get("id"),
|
|
381
|
+
"content_preview": existing.get("our_content"),
|
|
382
|
+
}))
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
# claude_session_id may come either via --claude-session-id or via the
|
|
386
|
+
# CLAUDE_SESSION_ID env var (set by run_claude.sh). CLI arg wins.
|
|
387
|
+
session_id = (getattr(args, "claude_session_id", None)
|
|
388
|
+
or os.environ.get("CLAUDE_SESSION_ID")
|
|
389
|
+
or None)
|
|
390
|
+
# Generation trace: opaque JSON blob captured by the generator before
|
|
391
|
+
# invoking Claude. Loaded from a file path (--generation-trace) because
|
|
392
|
+
# the JSON can be several KB and passing it inline via argv blows past
|
|
393
|
+
# macOS ARG_MAX. Passed to the API as a parsed object (the POST route
|
|
394
|
+
# serializes + caps at 1 MB). Failure to read just nulls the column —
|
|
395
|
+
# never blocks the post, since losing the audit row for one post is
|
|
396
|
+
# preferable to losing the post.
|
|
397
|
+
generation_trace_obj = None
|
|
398
|
+
trace_path = getattr(args, "generation_trace", None)
|
|
399
|
+
if trace_path:
|
|
400
|
+
try:
|
|
401
|
+
with open(trace_path, "r", encoding="utf-8") as tf:
|
|
402
|
+
generation_trace_obj = json.load(tf)
|
|
403
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
404
|
+
# Stderr only — stdout is reserved for the JSON envelope
|
|
405
|
+
# that post_github.py:log_post() parses.
|
|
406
|
+
print(f"WARNING: could not load generation_trace {trace_path}: {e}",
|
|
407
|
+
file=sys.stderr)
|
|
408
|
+
|
|
409
|
+
payload = {
|
|
410
|
+
"platform": "github",
|
|
411
|
+
"thread_url": args.thread_url,
|
|
412
|
+
"thread_author": args.thread_author,
|
|
413
|
+
"thread_title": args.thread_title,
|
|
414
|
+
"thread_content": "",
|
|
415
|
+
"our_url": args.our_url,
|
|
416
|
+
"our_content": args.our_text,
|
|
417
|
+
"our_account": args.account,
|
|
418
|
+
"source_summary": "",
|
|
419
|
+
"project": args.project,
|
|
420
|
+
"engagement_style": getattr(args, "engagement_style", None),
|
|
421
|
+
"search_topic": getattr(args, "search_topic", None),
|
|
422
|
+
"language": (getattr(args, "language", None) or "en"),
|
|
423
|
+
"claude_session_id": session_id,
|
|
424
|
+
"link_source": getattr(args, "link_source", None),
|
|
425
|
+
"autoposter_version": read_autoposter_version(),
|
|
426
|
+
}
|
|
427
|
+
if generation_trace_obj is not None:
|
|
428
|
+
payload["generation_trace"] = generation_trace_obj
|
|
429
|
+
resp = api_post("/api/v1/posts", payload, ok_on_conflict=True)
|
|
430
|
+
if not (resp or {}).get("ok"):
|
|
431
|
+
# Backstop: the POST route dedups (platform, thread_url) globally and
|
|
432
|
+
# 409s. Our per-account pre-check above already caught the common case;
|
|
433
|
+
# a 409 here is a cross-account thread collision. Surface DUPLICATE_THREAD.
|
|
434
|
+
e = (resp or {}).get("error") or {}
|
|
435
|
+
print(json.dumps({
|
|
436
|
+
"error": "DUPLICATE_THREAD",
|
|
437
|
+
"message": e.get("message") or "already posted in this thread",
|
|
438
|
+
"existing_post_id": (resp or {}).get("existing_post_id") or e.get("existing_post_id"),
|
|
439
|
+
}))
|
|
440
|
+
return
|
|
441
|
+
new_id = (((resp or {}).get("data") or {}).get("post") or {}).get("id")
|
|
442
|
+
# post_id surfaced so post_github.py:log_post can backfill post_links
|
|
443
|
+
# for click attribution. Shape mirrors log_post.py's INSERT envelope.
|
|
444
|
+
print(json.dumps({"logged": True, "post_id": new_id}))
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def main():
|
|
448
|
+
parser = argparse.ArgumentParser(description="GitHub tools for Claude")
|
|
449
|
+
sub = parser.add_subparsers(dest="command")
|
|
450
|
+
|
|
451
|
+
# search
|
|
452
|
+
p_search = sub.add_parser("search", help="Search GitHub issues")
|
|
453
|
+
p_search.add_argument("query")
|
|
454
|
+
p_search.add_argument("--limit", type=int, default=10)
|
|
455
|
+
|
|
456
|
+
# view
|
|
457
|
+
p_view = sub.add_parser("view", help="Fetch issue body + comments")
|
|
458
|
+
p_view.add_argument("repo", help="owner/repo")
|
|
459
|
+
p_view.add_argument("number", help="Issue number")
|
|
460
|
+
|
|
461
|
+
# already-posted
|
|
462
|
+
p_ap = sub.add_parser("already-posted", help="Check if we posted in this thread")
|
|
463
|
+
p_ap.add_argument("url")
|
|
464
|
+
|
|
465
|
+
# log-post
|
|
466
|
+
p_log = sub.add_parser("log-post", help="Log a posted comment to DB")
|
|
467
|
+
p_log.add_argument("thread_url")
|
|
468
|
+
p_log.add_argument("our_url")
|
|
469
|
+
p_log.add_argument("our_text")
|
|
470
|
+
p_log.add_argument("project")
|
|
471
|
+
p_log.add_argument("thread_author")
|
|
472
|
+
p_log.add_argument("thread_title")
|
|
473
|
+
p_log.add_argument("--account", default="m13v")
|
|
474
|
+
p_log.add_argument("--engagement-style", dest="engagement_style", default=None)
|
|
475
|
+
p_log.add_argument("--search-topic", dest="search_topic", default=None,
|
|
476
|
+
help="The seed topic/query used to find this issue (feedback loop input)")
|
|
477
|
+
p_log.add_argument("--language", dest="language", default=None,
|
|
478
|
+
help="ISO 639-1 language code of the issue (defaults to en if omitted)")
|
|
479
|
+
p_log.add_argument("--claude-session-id", dest="claude_session_id", default=None,
|
|
480
|
+
help="UUID of the Claude session that drafted this post (falls back to CLAUDE_SESSION_ID env var)")
|
|
481
|
+
p_log.add_argument("--generation-trace", dest="generation_trace", default=None,
|
|
482
|
+
help="Path to a JSON file with the few-shot context Claude "
|
|
483
|
+
"saw before drafting (top_performers report, recent "
|
|
484
|
+
"comments, top_search_topics, model, prompt size). "
|
|
485
|
+
"Stored in posts.generation_trace JSONB for audit. "
|
|
486
|
+
"See migrations/2026-05-12_generation_trace.sql for "
|
|
487
|
+
"the shape contract.")
|
|
488
|
+
p_log.add_argument("--link-source", dest="link_source", default=None,
|
|
489
|
+
help="Optional tag for posts.link_source so the dashboard "
|
|
490
|
+
"can break out audience-page traffic (e.g. "
|
|
491
|
+
"'audience_page:founder-ghostwriting') from generic "
|
|
492
|
+
"homepage links.")
|
|
493
|
+
|
|
494
|
+
args = parser.parse_args()
|
|
495
|
+
if args.command == "search":
|
|
496
|
+
cmd_search(args)
|
|
497
|
+
elif args.command == "view":
|
|
498
|
+
cmd_view(args)
|
|
499
|
+
elif args.command == "already-posted":
|
|
500
|
+
cmd_already_posted(args)
|
|
501
|
+
elif args.command == "log-post":
|
|
502
|
+
cmd_log_post(args)
|
|
503
|
+
else:
|
|
504
|
+
parser.print_help()
|
|
505
|
+
sys.exit(1)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
if __name__ == "__main__":
|
|
509
|
+
main()
|