@m13v/s4l 1.7.1-rc.2 → 1.7.1-rc.21
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/bin/cli.js +23 -0
- package/mcp/dist/index.js +332 -24
- package/mcp/dist/onboarding.js +3 -10
- package/mcp/dist/panel.html +42 -31
- package/mcp/dist/product-link.html +1 -1
- package/mcp/dist/runtime.js +101 -1
- package/mcp/dist/telemetry.js +80 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +9 -1
- package/mcp/menubar/dashboard_server.py +12 -0
- package/mcp/menubar/s4l_card.py +472 -96
- package/mcp/menubar/s4l_menubar.py +269 -26
- package/mcp/menubar/s4l_state.py +169 -23
- package/mcp/package.json +1 -1
- package/mcp/shared/doctor.cjs +8 -0
- package/mcp/shared/onboarding-ledger.cjs +1 -0
- package/package.json +3 -1
- package/scripts/active_users.py +3 -6
- package/scripts/autopilot_stall_watch.py +200 -16
- package/scripts/claude_job.py +6 -0
- package/scripts/feedback_digest.py +34 -6
- package/scripts/get-latest-staging-mcpb.sh +50 -0
- package/scripts/harness_overlay.py +45 -0
- package/scripts/identity.py +26 -0
- package/scripts/learned_preferences.py +241 -32
- package/scripts/link_tail.py +167 -91
- package/scripts/mark_event.py +128 -0
- package/scripts/memory_snapshot.py +62 -0
- package/scripts/pick_twitter_thread_target.py +1 -1
- package/scripts/release-mcpb.sh +48 -0
- package/scripts/reset-test-machine.sh +85 -3
- package/scripts/s4l_mode.py +7 -5
- package/scripts/scheduled_tasks_snapshot.py +107 -0
- package/scripts/send_gmail_report.py +76 -0
- package/scripts/sentry_digest.py +81 -189
- package/scripts/sentry_init.py +43 -15
- package/scripts/setup_twitter_auth.py +76 -38
- package/scripts/snapshot.py +20 -0
- package/scripts/twitter_post_plan.py +39 -13
- package/skill/dm-outreach-twitter.sh +19 -0
- package/skill/engage-twitter.sh +27 -0
- package/skill/run-draft-and-publish.sh +13 -12
- package/skill/run-linkedin.sh +9 -1
- package/skill/run-twitter-cycle.sh +199 -27
- package/skill/sentry-digest.sh +125 -12
package/scripts/release-mcpb.sh
CHANGED
|
@@ -45,6 +45,16 @@
|
|
|
45
45
|
# the EXACT tested artifact ships to everyone. Nothing can drift between test and
|
|
46
46
|
# ship because there is no repack.
|
|
47
47
|
#
|
|
48
|
+
# FIXED STAGING DOWNLOAD LINK (2026-07-07, step 7b): stable has a permanent
|
|
49
|
+
# "latest" URL (releases/latest/download/social-autoposter.mcpb); staging did
|
|
50
|
+
# not, because GitHub's releases/latest hard-excludes prereleases with no
|
|
51
|
+
# override. Every --staging run now also mirrors the .mcpb onto a separate,
|
|
52
|
+
# non-version-tagged release ("staging-latest") so this URL never changes:
|
|
53
|
+
# https://github.com/m13v/s4l/releases/download/staging-latest/social-autoposter.mcpb
|
|
54
|
+
# The tag deliberately isn't semver-shaped, so version.ts/snapshot.py's real
|
|
55
|
+
# "newest rc" resolution (which requires a ^\d+\.\d+\.\d+ tag) ignores it —
|
|
56
|
+
# it is a convenience download link only, never a candidate update source.
|
|
57
|
+
#
|
|
48
58
|
# Usage:
|
|
49
59
|
# bash scripts/release-mcpb.sh # patch bump, npm + .mcpb + GitHub (STABLE)
|
|
50
60
|
# bash scripts/release-mcpb.sh --bump minor
|
|
@@ -68,6 +78,12 @@ MCP_DIR="$REPO_ROOT/mcp"
|
|
|
68
78
|
BUNDLE="$MCP_DIR/social-autoposter.mcpb"
|
|
69
79
|
GH_REPO="m13v/s4l"
|
|
70
80
|
SIZE_CAP_MB=180
|
|
81
|
+
# Floating alias release for a fixed "always the newest staging build" download
|
|
82
|
+
# link, mirroring what releases/latest/download/<asset> already gives stable.
|
|
83
|
+
# GitHub's own releases/latest excludes prereleases with no override, so there is
|
|
84
|
+
# no built-in equivalent for the rc channel — see step 7b below for why a
|
|
85
|
+
# non-semver tag here is safe (never confuses the real update-detection code).
|
|
86
|
+
STAGING_ALIAS_TAG="staging-latest"
|
|
71
87
|
|
|
72
88
|
TAG_OVERRIDE=""
|
|
73
89
|
VERSION_OVERRIDE=""
|
|
@@ -496,6 +512,38 @@ say "Released $TAG"
|
|
|
496
512
|
echo " asset: social-autoposter.mcpb (${MB}MB)"
|
|
497
513
|
[[ -n "$URL" ]] && echo " $URL"
|
|
498
514
|
|
|
515
|
+
# ---- 7b. Fixed "always latest staging" download link ------------------------
|
|
516
|
+
# Mirrors the asset just uploaded above onto a SEPARATE release tagged
|
|
517
|
+
# "$STAGING_ALIAS_TAG" (not a version), so the download URL never changes:
|
|
518
|
+
# https://github.com/$GH_REPO/releases/download/$STAGING_ALIAS_TAG/social-autoposter.mcpb
|
|
519
|
+
# Safe by construction: latestFromGithubListStaging() (mcp/src/version.ts) and
|
|
520
|
+
# _latest_from_github_list_staging() (scripts/snapshot.py) both require
|
|
521
|
+
# tag_name to match ^\d+\.\d+\.\d+ before treating a release as a candidate
|
|
522
|
+
# "newest" build; a tag literally named "staging-latest" fails that check and
|
|
523
|
+
# is silently skipped by both, so this alias can never get mistaken for a real
|
|
524
|
+
# rc and can never confuse which build a box installs. It exists ONLY to give
|
|
525
|
+
# humans (and scripts) one fixed bookmark — actual boxes still resolve "newest"
|
|
526
|
+
# from the real per-release rc tags via the releases LIST endpoint, unchanged.
|
|
527
|
+
if [[ "$DO_STAGING" == "1" ]]; then
|
|
528
|
+
say "Mirroring asset onto the fixed staging-latest alias"
|
|
529
|
+
ALIAS_NOTES="Always mirrors the newest staging pre-release's .mcpb asset (currently $TAG).
|
|
530
|
+
|
|
531
|
+
Fixed download link (never changes): https://github.com/$GH_REPO/releases/download/$STAGING_ALIAS_TAG/social-autoposter.mcpb
|
|
532
|
+
|
|
533
|
+
Not a real release — do not install from the tag page; use the asset link above. Actual per-build releases are tagged by version (e.g. $TAG)."
|
|
534
|
+
if gh release view "$STAGING_ALIAS_TAG" -R "$GH_REPO" >/dev/null 2>&1; then
|
|
535
|
+
gh release upload "$STAGING_ALIAS_TAG" "$BUNDLE" -R "$GH_REPO" --clobber
|
|
536
|
+
gh release edit "$STAGING_ALIAS_TAG" -R "$GH_REPO" --notes "$ALIAS_NOTES" >/dev/null
|
|
537
|
+
else
|
|
538
|
+
gh release create "$STAGING_ALIAS_TAG" "$BUNDLE" \
|
|
539
|
+
-R "$GH_REPO" \
|
|
540
|
+
--title "social-autoposter (staging — always latest)" \
|
|
541
|
+
--notes "$ALIAS_NOTES" \
|
|
542
|
+
--prerelease
|
|
543
|
+
fi
|
|
544
|
+
echo " https://github.com/$GH_REPO/releases/download/$STAGING_ALIAS_TAG/social-autoposter.mcpb"
|
|
545
|
+
fi
|
|
546
|
+
|
|
499
547
|
# ---- 8. Verify the update banner will fire ---------------------------------
|
|
500
548
|
# The menu-bar "⬆ Update available" banner (mcp/src/version.ts::versionStatus)
|
|
501
549
|
# resolves "latest" from GitHub releases/latest, which is what .mcpb boxes (no
|
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
# disturbing the rest of the environment.
|
|
18
18
|
# --deep FULL NUKE — the plugin reset above PLUS the shared layer it touches:
|
|
19
19
|
# per-agent Chrome profiles + cookies (reddit/linkedin/twitter), the
|
|
20
|
-
# browser-harness backend,
|
|
21
|
-
# cache, Chromium ~1.7G)
|
|
22
|
-
#
|
|
20
|
+
# browser-harness backend, the packaged toolchain (uv binary + uv
|
|
21
|
+
# data/cache, Chromium ~1.7G), the uv PATH shim in your shell rc, the
|
|
22
|
+
# real Google Chrome.app the provisioner installs, and Google's
|
|
23
|
+
# Keystone updater. Reproduces a genuinely bare-metal box (as if S4L
|
|
24
|
+
# had never run). Other tools that rely on uv/Chrome/Chromium
|
|
25
|
+
# re-provision afterward — intended for DEDICATED test machines.
|
|
23
26
|
#
|
|
24
27
|
# Usage:
|
|
25
28
|
# scripts/reset-test-machine.sh # dry run: print what the plugin reset WOULD remove
|
|
@@ -205,6 +208,27 @@ echo "[1] MCP state dir (uv-owned python, venv, materialized repo, runtime.json,
|
|
|
205
208
|
rm_path "$HOME_DIR/.social-autoposter-mcp" "state"
|
|
206
209
|
echo
|
|
207
210
|
|
|
211
|
+
# ---- 1a. compat symlink ~/social-autoposter -> materialized repo -----------
|
|
212
|
+
# The .mcpb provisioner plants ~/social-autoposter as a symlink INTO the state
|
|
213
|
+
# dir (so the pipeline's hardcoded $HOME/social-autoposter paths resolve). The
|
|
214
|
+
# state-dir wipe in [1] leaves it DANGLING. Remove it — but ONLY when it is a
|
|
215
|
+
# symlink resolving into .social-autoposter-mcp, so a developer's real clone (or
|
|
216
|
+
# their own ~/social-autoposter symlink to a working tree) is never touched.
|
|
217
|
+
echo "[1a] compat symlink ~/social-autoposter (plugin-planted)"
|
|
218
|
+
SA_LINK="$HOME_DIR/social-autoposter"
|
|
219
|
+
if [ -L "$SA_LINK" ]; then
|
|
220
|
+
SA_TARGET="$(readlink "$SA_LINK" 2>/dev/null || true)"
|
|
221
|
+
case "$SA_TARGET" in
|
|
222
|
+
*.social-autoposter-mcp/*|*.social-autoposter-mcp) rm_path "$SA_LINK" "compat-symlink" ;;
|
|
223
|
+
*) echo " (leaving $SA_LINK -> $SA_TARGET — not a plugin-planted symlink)" ;;
|
|
224
|
+
esac
|
|
225
|
+
elif [ -e "$SA_LINK" ]; then
|
|
226
|
+
echo " (leaving $SA_LINK — a real directory/clone, not a plugin-planted symlink)"
|
|
227
|
+
else
|
|
228
|
+
echo " (no ~/social-autoposter symlink)"
|
|
229
|
+
fi
|
|
230
|
+
echo
|
|
231
|
+
|
|
208
232
|
# ---- 1b. Claude Desktop .mcpb extension -----------------------------------
|
|
209
233
|
# A .mcpb (Desktop) install scatters THREE artifacts under ~/Library/Application
|
|
210
234
|
# Support/Claude/, NONE of which live in the state dir removed in [1]: the
|
|
@@ -532,6 +556,23 @@ if [ "$DEEP" -eq 1 ]; then
|
|
|
532
556
|
rm_path "$HOME_DIR/.local/bin/uv" "uv-bin"
|
|
533
557
|
rm_path "$HOME_DIR/.local/bin/uvx" "uvx-bin"
|
|
534
558
|
rm_path "$HOME_DIR/.cache/uv" "uv-cache"
|
|
559
|
+
# uv's DATA dir (installed standalone Pythons + tool venvs) and its PATH shim
|
|
560
|
+
# survive the bin removal — a bare box has neither. The uv installer also
|
|
561
|
+
# appends `. "$HOME/.local/bin/env"` to the shell rc files; strip that exact
|
|
562
|
+
# line so no uv PATH shim lingers.
|
|
563
|
+
rm_path "$HOME_DIR/.local/share/uv" "uv-data"
|
|
564
|
+
rm_path "$HOME_DIR/.local/bin/env" "uv-env-script"
|
|
565
|
+
for rc in "$HOME_DIR/.zshrc" "$HOME_DIR/.zprofile" "$HOME_DIR/.profile" \
|
|
566
|
+
"$HOME_DIR/.bash_profile" "$HOME_DIR/.bashrc"; do
|
|
567
|
+
[ -f "$rc" ] || continue
|
|
568
|
+
if grep -q '\.local/bin/env' "$rc" 2>/dev/null; then
|
|
569
|
+
echo " edit strip uv env-shim line from $rc"
|
|
570
|
+
if [ "$DRY" -eq 0 ]; then
|
|
571
|
+
cp "$rc" "$rc.bak" 2>/dev/null || true
|
|
572
|
+
grep -v '\.local/bin/env' "$rc" > "$rc.tmp" 2>/dev/null && mv "$rc.tmp" "$rc"
|
|
573
|
+
fi
|
|
574
|
+
fi
|
|
575
|
+
done
|
|
535
576
|
echo " WARN --deep removed uv + ms-playwright; other tools on this machine that rely on them will re-provision."
|
|
536
577
|
else
|
|
537
578
|
echo " (left uv + ~/Library/Caches/ms-playwright + ~/.cache/uv in place)"
|
|
@@ -539,6 +580,47 @@ fi
|
|
|
539
580
|
fi
|
|
540
581
|
echo
|
|
541
582
|
|
|
583
|
+
# ---- 6b. DEEP: packaged Google Chrome + its Keystone updater ----------------
|
|
584
|
+
# The provisioner installs a REAL Google Chrome.app (runtime.ts Step 7) — the
|
|
585
|
+
# browser the scanner drives over CDP — that NO other reset step removes, plus
|
|
586
|
+
# installing it drops Google's Keystone auto-updater (LaunchAgents + support
|
|
587
|
+
# dirs). A bare box had none of this. --deep removes all of it so the Chrome
|
|
588
|
+
# install step is exercised fresh. NOTE: this touches the machine's Google Chrome
|
|
589
|
+
# globally, not just S4L — hence --deep / test-box only.
|
|
590
|
+
if [ "$PLUGIN_ONLY" -eq 1 ]; then
|
|
591
|
+
echo "[6b] packaged Google Chrome + Keystone updater — skipped (plugin reset; pass --deep to remove)"
|
|
592
|
+
else
|
|
593
|
+
echo "[6b] packaged Google Chrome + Keystone updater — $([ "$DEEP" -eq 1 ] && echo ENABLED || echo 'skipped (pass --deep)')"
|
|
594
|
+
if [ "$DEEP" -eq 1 ]; then
|
|
595
|
+
# Stop Chrome + unload the Keystone/updater agents before deleting their plists.
|
|
596
|
+
if [ "$DRY" -eq 0 ]; then
|
|
597
|
+
pkill -f 'Google Chrome' 2>/dev/null || true
|
|
598
|
+
for lbl in com.google.keystone.agent com.google.keystone.xpcservice com.google.GoogleUpdater.wake; do
|
|
599
|
+
launchctl bootout "gui/$(id -u)/$lbl" 2>/dev/null || true
|
|
600
|
+
done
|
|
601
|
+
fi
|
|
602
|
+
rm_path "/Applications/Google Chrome.app" "google-chrome"
|
|
603
|
+
rm_path "$HOME_DIR/Applications/Google Chrome.app" "google-chrome-user"
|
|
604
|
+
for p in \
|
|
605
|
+
"$HOME_DIR/Library/Application Support/Google" \
|
|
606
|
+
"$HOME_DIR/Library/Google" \
|
|
607
|
+
"$HOME_DIR/Library/Caches/Google" \
|
|
608
|
+
"$HOME_DIR/Library/Caches/com.google.Chrome" \
|
|
609
|
+
"$HOME_DIR/Library/Caches/com.google.Keystone" \
|
|
610
|
+
"$HOME_DIR/Library/LaunchAgents/com.google.keystone.agent.plist" \
|
|
611
|
+
"$HOME_DIR/Library/LaunchAgents/com.google.keystone.xpcservice.plist" \
|
|
612
|
+
"$HOME_DIR/Library/LaunchAgents/com.google.GoogleUpdater.wake.plist" \
|
|
613
|
+
"$HOME_DIR/Library/LaunchAgents/com.google.GoogleUpdater.plist"; do
|
|
614
|
+
rm_path "$p" "google"
|
|
615
|
+
done
|
|
616
|
+
for f in "$HOME_DIR"/Library/Preferences/com.google.Chrome*.plist; do
|
|
617
|
+
[ -e "$f" ] && rm_path "$f" "google-pref"
|
|
618
|
+
done
|
|
619
|
+
echo " WARN --deep removed Google Chrome + Keystone updater machine-wide."
|
|
620
|
+
fi
|
|
621
|
+
fi
|
|
622
|
+
echo
|
|
623
|
+
|
|
542
624
|
# ---- 7. settle & verify (defeat Claude auto-relaunch resurrection) ---------
|
|
543
625
|
# Claude Desktop can RELAUNCH after step [0] — on auto-update (ShipIt), a macOS
|
|
544
626
|
# re-open, or a login item — and a relaunched host re-materializes a skeleton
|
package/scripts/s4l_mode.py
CHANGED
|
@@ -249,11 +249,13 @@ def set_split(value) -> float:
|
|
|
249
249
|
def get_draft_only() -> bool:
|
|
250
250
|
"""Whether every cycle stops at review cards (DEFAULT: True).
|
|
251
251
|
|
|
252
|
-
False (operator opt-out via `draft-only off`) means
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
252
|
+
False (operator opt-out via `draft-only off`) means EVERY lane (promotion
|
|
253
|
+
AND personal_brand) POSTs autonomously; promotion additionally runs behind
|
|
254
|
+
the rolling virality bar. As of 2026-07-08 this is a single global switch,
|
|
255
|
+
not promotion-only: see skill/run-draft-and-publish.sh's DRAFT_ONLY_FLAG
|
|
256
|
+
gate, which applies this value uniformly across lanes. Reads the retired
|
|
257
|
+
`autopilot` key (inverted) from mode.json files written before the
|
|
258
|
+
2026-07-06 rename.
|
|
257
259
|
"""
|
|
258
260
|
try:
|
|
259
261
|
data = json.loads(mode_file().read_text())
|
|
@@ -159,6 +159,108 @@ def build_summary() -> dict:
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
|
|
162
|
+
STATE_CACHE_PATH = os.path.join(
|
|
163
|
+
os.path.expanduser("~"), ".social-autoposter-mcp", "scheduled-tasks-last-state.json"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _load_last_state() -> dict:
|
|
168
|
+
try:
|
|
169
|
+
with open(STATE_CACHE_PATH) as f:
|
|
170
|
+
return json.load(f)
|
|
171
|
+
except Exception:
|
|
172
|
+
return {}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _save_last_state(state: dict) -> None:
|
|
176
|
+
try:
|
|
177
|
+
os.makedirs(os.path.dirname(STATE_CACHE_PATH), exist_ok=True)
|
|
178
|
+
tmp = f"{STATE_CACHE_PATH}.tmp.{os.getpid()}"
|
|
179
|
+
with open(tmp, "w") as f:
|
|
180
|
+
json.dump(state, f)
|
|
181
|
+
os.replace(tmp, STATE_CACHE_PATH)
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _registry_key(reg: dict) -> str:
|
|
187
|
+
return f"{reg.get('data_dir')}/{reg.get('account')}/{reg.get('session')}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _report_registry_change(message: str, changes: list) -> None:
|
|
191
|
+
"""Best-effort Sentry breadcrumb for a registry transition. Silent no-op if
|
|
192
|
+
sentry-sdk/S4L_REPO_DIR are unavailable, so this stays additive and never
|
|
193
|
+
threatens the script's primary --summary (heartbeat) path."""
|
|
194
|
+
try:
|
|
195
|
+
repo = os.environ.get("S4L_REPO_DIR")
|
|
196
|
+
if repo:
|
|
197
|
+
scripts_dir = os.path.join(repo, "scripts")
|
|
198
|
+
if scripts_dir not in sys.path:
|
|
199
|
+
sys.path.insert(0, scripts_dir)
|
|
200
|
+
import sentry_init
|
|
201
|
+
|
|
202
|
+
sentry_init.init()
|
|
203
|
+
sentry_init.capture_message(
|
|
204
|
+
message,
|
|
205
|
+
level="info",
|
|
206
|
+
tags={"component": "scheduled-tasks-snapshot", "issue": "registry_change"},
|
|
207
|
+
extra={"changes": changes},
|
|
208
|
+
)
|
|
209
|
+
sentry_init.flush()
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def diff_and_emit(per_registry: list) -> list:
|
|
215
|
+
"""Compare the current per-registry worker_ids/enabled state against the
|
|
216
|
+
last observed state (cached locally) and emit ONE distinct event per real
|
|
217
|
+
transition: a registry gaining/losing the worker task, `enabled` flipping,
|
|
218
|
+
or a registry appearing/disappearing entirely. Silent when nothing changed.
|
|
219
|
+
|
|
220
|
+
This exists because reconstructing "when did the registry actually change"
|
|
221
|
+
previously required diffing dozens of raw --summary snapshots by hand (see
|
|
222
|
+
the Karol 2026-07-07 update-orphan investigation) — every poll logged full
|
|
223
|
+
state unconditionally, with no distinct signal for an actual transition.
|
|
224
|
+
Returns the list of change strings (empty if nothing changed). Best-effort:
|
|
225
|
+
never raises, and never touches the registry files themselves."""
|
|
226
|
+
changes: list = []
|
|
227
|
+
try:
|
|
228
|
+
last = _load_last_state()
|
|
229
|
+
current = {}
|
|
230
|
+
for reg in per_registry:
|
|
231
|
+
key = _registry_key(reg)
|
|
232
|
+
snap = {
|
|
233
|
+
"worker_ids": sorted(reg.get("worker_ids") or []),
|
|
234
|
+
"enabled": bool(reg.get("enabled")),
|
|
235
|
+
}
|
|
236
|
+
current[key] = snap
|
|
237
|
+
prev = last.get(key)
|
|
238
|
+
if prev is None:
|
|
239
|
+
if snap["worker_ids"]:
|
|
240
|
+
changes.append(
|
|
241
|
+
f"{key}: registry appeared with worker_ids={snap['worker_ids']} "
|
|
242
|
+
f"enabled={snap['enabled']}"
|
|
243
|
+
)
|
|
244
|
+
continue
|
|
245
|
+
if prev.get("worker_ids") != snap["worker_ids"] or prev.get("enabled") != snap["enabled"]:
|
|
246
|
+
changes.append(
|
|
247
|
+
f"{key}: worker_ids {prev.get('worker_ids')}->{snap['worker_ids']} "
|
|
248
|
+
f"enabled {prev.get('enabled')}->{snap['enabled']}"
|
|
249
|
+
)
|
|
250
|
+
for key, prev in last.items():
|
|
251
|
+
if key not in current and (prev or {}).get("worker_ids"):
|
|
252
|
+
changes.append(f"{key}: registry disappeared (had worker_ids={prev.get('worker_ids')})")
|
|
253
|
+
|
|
254
|
+
if changes:
|
|
255
|
+
message = "scheduled-tasks registry changed: " + "; ".join(changes)
|
|
256
|
+
sys.stderr.write(f"[scheduled-tasks-snapshot] {message}\n")
|
|
257
|
+
_report_registry_change(message, changes)
|
|
258
|
+
_save_last_state(current)
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
return changes
|
|
262
|
+
|
|
263
|
+
|
|
162
264
|
def main() -> int:
|
|
163
265
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
164
266
|
parser.add_argument(
|
|
@@ -169,6 +271,11 @@ def main() -> int:
|
|
|
169
271
|
args = parser.parse_args()
|
|
170
272
|
|
|
171
273
|
summary = build_summary()
|
|
274
|
+
# Side effect deliberately confined to the standalone-script path (not
|
|
275
|
+
# build_summary(), which s4l_menubar.py also imports in-process on every
|
|
276
|
+
# warning capture — doing the diff there would cache-thrash and could
|
|
277
|
+
# double-report on every menu-bar poll instead of once per real change).
|
|
278
|
+
diff_and_emit(summary.get("per_registry") or [])
|
|
172
279
|
if args.summary:
|
|
173
280
|
sys.stdout.write(json.dumps(summary, separators=(",", ":")))
|
|
174
281
|
else:
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generic Gmail-API send helper for Claude-authored reports.
|
|
3
|
+
|
|
4
|
+
Extracted out of sentry_digest.py so any skill doc (Claude spawned in a
|
|
5
|
+
--dangerously-skip-permissions session) can send a report email without
|
|
6
|
+
duplicating the Gmail plumbing. Body is read from a file, not a CLI arg, so
|
|
7
|
+
large HTML/markdown reports don't hit shell quoting/length limits.
|
|
8
|
+
|
|
9
|
+
Same Gmail token as strike_alert.py / daily_stats_email.py: i@m13v.com via
|
|
10
|
+
~/gmail-api/token_i_at_m13v.com.json.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python3 scripts/send_gmail_report.py \\
|
|
14
|
+
--to i@m13v.com \\
|
|
15
|
+
--subject "[Sentry] s4l: 1 new critical issue" \\
|
|
16
|
+
--body-file /tmp/report.html \\
|
|
17
|
+
[--html] # body-file content is HTML (default: plain text)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import base64
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from email.mime.text import MIMEText
|
|
25
|
+
|
|
26
|
+
GMAIL_TOKEN_PATH = os.path.expanduser("~/gmail-api/token_i_at_m13v.com.json")
|
|
27
|
+
GMAIL_SCOPES = ["https://mail.google.com/"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _scrub_dashes(s):
|
|
31
|
+
if not s:
|
|
32
|
+
return s
|
|
33
|
+
return s.replace("—", ",").replace("–", ",")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def gmail_service():
|
|
37
|
+
from google.auth.transport.requests import Request
|
|
38
|
+
from google.oauth2.credentials import Credentials
|
|
39
|
+
from googleapiclient.discovery import build
|
|
40
|
+
|
|
41
|
+
creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
|
|
42
|
+
if creds.expired and creds.refresh_token:
|
|
43
|
+
creds.refresh(Request())
|
|
44
|
+
with open(GMAIL_TOKEN_PATH, "w") as f:
|
|
45
|
+
f.write(creds.to_json())
|
|
46
|
+
return build("gmail", "v1", credentials=creds)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main():
|
|
50
|
+
parser = argparse.ArgumentParser()
|
|
51
|
+
parser.add_argument("--to", required=True)
|
|
52
|
+
parser.add_argument("--subject", required=True)
|
|
53
|
+
parser.add_argument("--body-file", required=True, help="Path to a file containing the email body.")
|
|
54
|
+
parser.add_argument("--html", action="store_true", help="Body file content is HTML (default: plain text).")
|
|
55
|
+
args = parser.parse_args()
|
|
56
|
+
|
|
57
|
+
with open(args.body_file) as f:
|
|
58
|
+
body = f.read()
|
|
59
|
+
|
|
60
|
+
subtype = "html" if args.html else "plain"
|
|
61
|
+
msg = MIMEText(body, subtype)
|
|
62
|
+
msg["to"] = args.to
|
|
63
|
+
msg["subject"] = _scrub_dashes(args.subject)
|
|
64
|
+
|
|
65
|
+
service = gmail_service()
|
|
66
|
+
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
|
|
67
|
+
result = service.users().messages().send(userId="me", body={"raw": raw}).execute()
|
|
68
|
+
print(f"Email sent: {result['id']}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
try:
|
|
73
|
+
main()
|
|
74
|
+
except Exception as e:
|
|
75
|
+
print(f"ERROR sending email: {e}", file=sys.stderr)
|
|
76
|
+
sys.exit(1)
|