social-autoposter 1.6.22 → 1.6.24

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 CHANGED
@@ -96,6 +96,45 @@ function findUvBin() {
96
96
  return found && fs.existsSync(found) ? found : '';
97
97
  }
98
98
 
99
+ // Locate the Python the MCP server is actually configured to run (SAPS_PYTHON).
100
+ // mcp/install.mjs picks /opt/homebrew/bin/python3 (or /usr/local/bin/python3)
101
+ // and stamps it into the MCP config, so Python deps MUST be installed into that
102
+ // SAME interpreter. Bare `pip3`/`python3` on macOS usually resolves to the
103
+ // Xcode CLT system python (3.9.x with pip 21.x), which is both the wrong target
104
+ // and too old to understand --break-system-packages. Falls back to `python3`.
105
+ function findPythonBin() {
106
+ const candidates = ['/opt/homebrew/bin/python3', '/usr/local/bin/python3'];
107
+ for (const c of candidates) {
108
+ if (fs.existsSync(c)) return c;
109
+ }
110
+ const which = spawnSync('command', ['-v', 'python3'], { shell: true, encoding: 'utf8' });
111
+ const found = (which.stdout || '').trim().split('\n')[0];
112
+ return found && fs.existsSync(found) ? found : 'python3';
113
+ }
114
+
115
+ // True if `<pythonBin> -m pip` is new enough (pip >= 23.0) to accept
116
+ // --break-system-packages. Older pips treat the flag as an unknown option and
117
+ // hard-fail, so we must not pass it blindly on the retry.
118
+ function pipSupportsBreakSystemPackages(pythonBin) {
119
+ const v = spawnSync(pythonBin, ['-m', 'pip', '--version'], { encoding: 'utf8' });
120
+ const m = (v.stdout || '').match(/pip\s+(\d+)\.(\d+)/);
121
+ if (!m) return false;
122
+ return parseInt(m[1], 10) >= 23;
123
+ }
124
+
125
+ // Install Python packages into a specific interpreter via `<py> -m pip install`.
126
+ // Retries with --break-system-packages only when the resolved pip supports it
127
+ // (PEP 668 environments: Homebrew python, Debian/Ubuntu 23+). Returns the
128
+ // spawnSync result of the last attempt.
129
+ function pipInstall(pythonBin, args) {
130
+ const base = ['-m', 'pip', 'install', ...args];
131
+ let r = spawnSync(pythonBin, base, { stdio: 'inherit' });
132
+ if (r.status !== 0 && pipSupportsBreakSystemPackages(pythonBin)) {
133
+ r = spawnSync(pythonBin, [...base, '--break-system-packages'], { stdio: 'inherit' });
134
+ }
135
+ return r;
136
+ }
137
+
99
138
  function installBrowserAgentConfigs() {
100
139
  const nodeBin = path.dirname(process.execPath);
101
140
  const uvBin = findUvBin() || path.join(HOME, '.local', 'bin', 'uv');
@@ -489,11 +528,13 @@ function installBrowserHarness() {
489
528
 
490
529
  // Step 4: ensure mcp Python package available (server.py uses `from mcp.server.fastmcp ...`).
491
530
  // server.py is shebanged through `uv run --with mcp ...` so this is belt-and-suspenders;
492
- // we install it into the system Python too so a plain `python3 server.py` also works.
493
- console.log(' ensuring mcp>=1.0.0 Python package is importable...');
494
- let pip = spawnSync('pip3', ['install', '-q', 'mcp>=1.0.0'], { stdio: 'inherit' });
531
+ // we install it into the SAPS_PYTHON interpreter (the same Homebrew python the MCP
532
+ // server is configured to use), NOT bare pip3 which targets the Xcode CLT system python.
533
+ const harnessPython = findPythonBin();
534
+ console.log(` ensuring mcp>=1.0.0 Python package is importable (${harnessPython})...`);
535
+ const pip = pipInstall(harnessPython, ['-q', 'mcp>=1.0.0']);
495
536
  if (pip.status !== 0) {
496
- pip = spawnSync('pip3', ['install', '-q', 'mcp>=1.0.0', '--break-system-packages'], { stdio: 'inherit' });
537
+ console.warn(' WARNING: could not install mcp Python package; server.py still runs via `uv run --with mcp`.');
497
538
  }
498
539
 
499
540
  // Step 5: copy our shipped server.py into the canonical install location.
@@ -836,30 +877,29 @@ function update() {
836
877
  // browser binary; we run `playwright install chromium` after the pip install.
837
878
  function installPythonDeps() {
838
879
  const reqPath = path.join(PKG_ROOT, 'requirements.txt');
839
- const base = fs.existsSync(reqPath)
840
- ? ['install', '-r', reqPath, '-q']
841
- : ['install', '-q', 'psycopg2-binary', 'playwright'];
842
- console.log(' installing Python deps (psycopg2-binary, playwright, ...)');
843
- // Debian/Ubuntu 23+ ship a PEP 668 marker that blocks pip3 against the
844
- // system Python without --break-system-packages. Try without first
845
- // (safer on macOS) and retry with the flag if the marker fires.
846
- let r = spawnSync('pip3', base, { stdio: 'inherit' });
847
- if (r.status !== 0) {
848
- console.log(' retrying with --break-system-packages (PEP 668 environments)');
849
- r = spawnSync('pip3', [...base, '--break-system-packages'], { stdio: 'inherit' });
850
- }
880
+ const args = fs.existsSync(reqPath)
881
+ ? ['-r', reqPath, '-q']
882
+ : ['-q', 'psycopg2-binary', 'playwright'];
883
+ // Install into the SAME interpreter the MCP server runs (SAPS_PYTHON =
884
+ // Homebrew python), NOT bare pip3 which on macOS targets the Xcode CLT system
885
+ // python deps installed there are invisible to the scripts at runtime.
886
+ // pipInstall() also gates --break-system-packages on pip>=23 so it doesn't
887
+ // hard-fail against the ancient system pip.
888
+ const pythonBin = findPythonBin();
889
+ console.log(` installing Python deps (psycopg2-binary, playwright, ...) into ${pythonBin}`);
890
+ const r = pipInstall(pythonBin, args);
851
891
  if (r.status !== 0) {
852
- console.warn(' WARNING: pip3 install failed — run manually:');
853
- console.warn(` pip3 ${base.join(' ')} --break-system-packages`);
892
+ console.warn(' WARNING: pip install failed — run manually:');
893
+ console.warn(` ${pythonBin} -m pip install ${args.join(' ')} --break-system-packages`);
854
894
  return;
855
895
  }
856
896
  // Playwright needs its browser binary downloaded separately. Chromium
857
897
  // is the only engine the repo uses today; skip Firefox/WebKit.
858
898
  console.log(' installing Playwright Chromium binary (one-time, ~150MB)...');
859
- const pw = spawnSync('python3', ['-m', 'playwright', 'install', 'chromium'], { stdio: 'inherit' });
899
+ const pw = spawnSync(pythonBin, ['-m', 'playwright', 'install', 'chromium'], { stdio: 'inherit' });
860
900
  if (pw.status !== 0) {
861
901
  console.warn(' WARNING: playwright install chromium failed — run manually:');
862
- console.warn(' python3 -m playwright install chromium');
902
+ console.warn(` ${pythonBin} -m playwright install chromium`);
863
903
  }
864
904
  }
865
905
 
package/mcp/dist/index.js CHANGED
@@ -242,7 +242,12 @@ async function postApproved(batchId, plan) {
242
242
  return { attempted: 0, exit_code: 0, summary: "nothing approved" };
243
243
  const approvedBatch = `${batchId}_approved`;
244
244
  writePlan(approvedBatch, { ...plan, candidates: approved });
245
- const res = await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], { timeoutMs: 900_000 });
245
+ // SAPS_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
246
+ // never get the active-campaign suffix (e.g. " written with ai") appended.
247
+ // twitter_browser.py's reply handler reads this env (inherited through
248
+ // twitter_post_plan.py's subprocess). The cron pipeline doesn't set it, so the
249
+ // A/B disclosure experiment keeps running on autopilot/cron and on Reddit.
250
+ const res = await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], { timeoutMs: 900_000, env: { SAPS_SKIP_CAMPAIGN_SUFFIX: "1" } });
246
251
  let summary = res.stdout.trim();
247
252
  try {
248
253
  const lines = res.stdout.trim().split("\n");
package/mcp/install.mjs CHANGED
@@ -153,32 +153,13 @@ if (ok === targets.length) {
153
153
  console.log("server: " + distEntry);
154
154
  console.log("");
155
155
  console.log(
156
- "AGENT INSTRUCTION: The MCP server is registered but is NOT loaded in this",
157
- );
158
- console.log(
159
- "session yet (MCP servers load at launch, not per-tab). End your reply to the",
160
- );
161
- console.log(
162
- "user with EXACTLY the following three steps as the FINAL lines of your response,",
163
- );
164
- console.log(
165
- "each on its own line, in bold, numbered, with nothing after them:",
156
+ "The MCP server is registered but not yet loaded (MCP servers load at launch,",
166
157
  );
158
+ console.log("not per-tab). To finish setup:");
167
159
  console.log("");
168
- console.log(
169
- " **1. Fully quit Claude now: press Cmd+Q (closing the window is not enough).**",
170
- );
171
- console.log(" **2. Reopen Claude.**");
172
- console.log(
173
- ' **3. In the message box, type: Set me up on social-autoposter. and send it.**',
174
- );
175
- console.log("");
176
- console.log(
177
- "Do not paraphrase those three lines, do not add commentary after them, and do",
178
- );
179
- console.log(
180
- "not skip the bold. They are the only way the user can start the autoposter.",
181
- );
160
+ console.log(" 1. Fully quit Claude (Cmd+Q; closing the window is not enough).");
161
+ console.log(" 2. Reopen Claude.");
162
+ console.log(' 3. Send: "Set me up on social-autoposter."');
182
163
  }
183
164
  } else {
184
165
  console.error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.22",
3
+ "version": "1.6.24",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
package/requirements.txt CHANGED
@@ -1,7 +1,8 @@
1
1
  # social-autoposter Python deps.
2
- # Installed by bin/cli.js via `pip3 install -r requirements.txt` on init/update.
3
- # Playwright browser binaries are downloaded separately (CLI runs
4
- # `python3 -m playwright install chromium`).
2
+ # Installed by bin/cli.js on init/update into the SAPS_PYTHON interpreter
3
+ # (Homebrew python), via `<saps_python> -m pip install -r requirements.txt`.
4
+ # Playwright browser binaries are downloaded separately (same interpreter:
5
+ # `<saps_python> -m playwright install chromium`).
5
6
 
6
7
  psycopg2-binary
7
8
  playwright
@@ -14,9 +14,10 @@ Flow:
14
14
  `[SEO #<id>]` in the subject by default.
15
15
  3. This script polls i@m13v.com for `is:unread subject:"Re: [SEO #"`.
16
16
  For each match it: extracts the escalation id, strips quoted history
17
- from the body, and UPDATEs seo_escalations SET status='replied',
18
- human_reply=..., replied_at=NOW(), gmail_inbound_id=... WHERE
19
- id=N AND status='pending'.
17
+ from the body, and PATCHes /api/v1/seo/escalations
18
+ (action=ingest_reply) which sets status='replied', human_reply,
19
+ replied_at=NOW(), gmail_inbound_id WHERE id=N AND status='pending'.
20
+ All state goes through the s4l.ai HTTP API (HTTP-only, no DATABASE_URL).
20
21
  4. Marks the Gmail message as read so it is not re-ingested.
21
22
  5. seo/resume_escalations.py (run from cron_seo.sh) picks up rows with
22
23
  status='replied' and re-invokes generate_page.py --resume-escalation N.
@@ -37,8 +38,10 @@ from pathlib import Path
37
38
 
38
39
  SCRIPT_DIR = Path(__file__).resolve().parent
39
40
  SEO_DIR = SCRIPT_DIR.parent / "seo"
40
- sys.path.insert(0, str(SEO_DIR))
41
- import db_helpers
41
+ sys.path.insert(0, str(SCRIPT_DIR))
42
+ from http_api import api_get, api_patch, load_env # noqa: E402
43
+
44
+ load_env()
42
45
 
43
46
  from google.auth.transport.requests import Request
44
47
  from google.oauth2.credentials import Credentials
@@ -175,9 +178,6 @@ def main():
175
178
  print("No candidate Gmail messages for SEO escalation replies.")
176
179
  return
177
180
 
178
- conn = db_helpers.get_conn()
179
- cur = conn.cursor()
180
-
181
181
  ingested = 0
182
182
  skipped = 0
183
183
  for c in candidates:
@@ -205,19 +205,20 @@ def main():
205
205
  skipped += 1
206
206
  continue
207
207
 
208
- cur.execute(
209
- "SELECT id, status, product, keyword, gmail_inbound_id "
210
- "FROM seo_escalations WHERE id = %s",
211
- (escalation_id,),
212
- )
213
- esc_row = cur.fetchone()
208
+ show = api_get("/api/v1/seo/escalations",
209
+ query={"mode": "show", "id": escalation_id}, ok_on_404=True)
210
+ esc_row = None if show.get("_not_found") else (show.get("data") or {}).get("row")
214
211
  if not esc_row:
215
212
  print(f" SKIP {gmail_id}: escalation #{escalation_id} not found")
216
213
  skipped += 1
217
214
  mark_read(service, gmail_id) if not args.dry_run else None
218
215
  continue
219
216
 
220
- eid, status, product, keyword, existing_inbound = esc_row
217
+ eid = esc_row.get("id")
218
+ status = esc_row.get("status")
219
+ product = esc_row.get("product")
220
+ keyword = esc_row.get("keyword")
221
+ existing_inbound = esc_row.get("gmail_inbound_id")
221
222
 
222
223
  # Idempotency: if the row already has an inbound id (already ingested),
223
224
  # just mark Gmail as read and move on. If status is not pending, we
@@ -249,27 +250,19 @@ def main():
249
250
  continue
250
251
 
251
252
  try:
252
- cur.execute(
253
- """
254
- UPDATE seo_escalations
255
- SET status = 'replied',
256
- human_reply = %s,
257
- replied_at = NOW(),
258
- gmail_inbound_id = %s,
259
- updated_at = NOW()
260
- WHERE id = %s AND status = 'pending'
261
- """,
262
- (reply_text, gmail_id, eid),
263
- )
264
- if cur.rowcount == 0:
253
+ presp = api_patch("/api/v1/seo/escalations", {
254
+ "id": eid,
255
+ "action": "ingest_reply",
256
+ "human_reply": reply_text,
257
+ "gmail_inbound_id": gmail_id,
258
+ })
259
+ updated = int((presp.get("data") or {}).get("updated") or 0)
260
+ if updated == 0:
265
261
  print(f" ERROR {gmail_id}: UPDATE matched 0 rows (race?); leaving message unread")
266
262
  skipped += 1
267
- conn.rollback()
268
263
  continue
269
- conn.commit()
270
264
  except Exception as e:
271
265
  print(f" ERROR {gmail_id}: update failed: {e}")
272
- conn.rollback()
273
266
  skipped += 1
274
267
  continue
275
268
 
@@ -280,8 +273,6 @@ def main():
280
273
  )
281
274
  ingested += 1
282
275
 
283
- cur.close()
284
- conn.close()
285
276
  print(f"Done. Ingested={ingested} skipped={skipped} candidates={len(candidates)}")
286
277
 
287
278
 
@@ -2072,7 +2072,14 @@ def main():
2072
2072
  file=sys.stderr,
2073
2073
  )
2074
2074
  sys.exit(1)
2075
- result = reply_to_tweet(sys.argv[2], sys.argv[3])
2075
+ # SAPS_SKIP_CAMPAIGN_SUFFIX=1 opts this reply out of active-campaign
2076
+ # suffixes (e.g. " written with ai"). Set ONLY by the MCP draft_cycle
2077
+ # post path (mcp/src/index.ts::postApproved) so manual/reviewed posts
2078
+ # land clean; the cron pipeline never sets it, so the A/B experiment
2079
+ # keeps running there and on Reddit. Reuses the existing apply_campaigns
2080
+ # plumbing (same flag the self-reply path uses below).
2081
+ _skip_camp = os.environ.get("SAPS_SKIP_CAMPAIGN_SUFFIX", "").strip().lower() in ("1", "true", "yes")
2082
+ result = reply_to_tweet(sys.argv[2], sys.argv[3], apply_campaigns=not _skip_camp)
2076
2083
  print(json.dumps(result, indent=2))
2077
2084
 
2078
2085
  elif cmd == "like":