@m13v/s4l 1.7.7 → 1.7.8-rc.2

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.
@@ -0,0 +1,119 @@
1
+ #!/bin/bash
2
+ # S4L universal installer: curl -fsSL https://s4l.ai/install | sh
3
+ #
4
+ # Zero-toolchain by design: needs only stock macOS (curl, unzip, python3).
5
+ # Downloads the release bundle (the .mcpb zip, which vendors its own Node
6
+ # runtime and the whole pipeline), extracts it to a durable host-neutral dir,
7
+ # registers the MCP server with whichever agent app is present (ChatGPT/Codex
8
+ # and/or Claude Code), and stamps the draft provider for the invoking host.
9
+ # The heavy runtime (Python venv, Chromium) self-provisions on first server
10
+ # boot, exactly as it does for .mcpb installs.
11
+ #
12
+ # Host detection order:
13
+ # 1. Invoking agent env: CODEX_THREAD_ID => codex chat; CLAUDE_CODE_*/CLAUDECODE
14
+ # => Claude Code chat. The invoking host's engine becomes the draft provider.
15
+ # 2. Otherwise: whatever is installed (both is fine; provider prefers claude-p
16
+ # when the claude CLI exists, else codex-exec).
17
+ #
18
+ # Channels: S4L_CHANNEL=staging pulls the staging-latest prerelease instead of
19
+ # the stable release. Optional S4L_INSTALL_HOST=codex|claude forces the provider
20
+ # choice regardless of detection.
21
+
22
+ set -euo pipefail
23
+
24
+ REPO="m13v/s4l"
25
+ STABLE_URL="https://github.com/$REPO/releases/latest/download/social-autoposter.mcpb"
26
+ STAGING_URL="https://github.com/$REPO/releases/download/staging-latest/social-autoposter.mcpb"
27
+ STATE_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}"
28
+ APP_DIR="$STATE_DIR/app"
29
+ CODEX_BIN="/Applications/ChatGPT.app/Contents/Resources/codex"
30
+
31
+ log() { echo "[s4l-install] $*"; }
32
+ die() { echo "[s4l-install] ERROR: $*" >&2; exit 1; }
33
+
34
+ [ "$(uname -s)" = "Darwin" ] || die "macOS only for now."
35
+ [ "$(uname -m)" = "arm64" ] || die "Apple Silicon only for now (the bundle vendors a darwin-arm64 Node)."
36
+
37
+ URL="$STABLE_URL"
38
+ [ "${S4L_CHANNEL:-}" = "staging" ] && URL="$STAGING_URL"
39
+
40
+ # 1. Download + extract. The .mcpb is a plain zip.
41
+ TMP="$(mktemp -d /tmp/s4l-install.XXXXXX)"
42
+ trap 'rm -rf "$TMP"' EXIT
43
+ log "downloading $URL"
44
+ /usr/bin/curl -fsSL -o "$TMP/s4l.mcpb" "$URL" || die "download failed"
45
+ mkdir -p "$TMP/app"
46
+ /usr/bin/unzip -q "$TMP/s4l.mcpb" -d "$TMP/app" || die "unzip failed"
47
+ [ -f "$TMP/app/dist/index.js" ] || die "bundle missing dist/index.js (bad download?)"
48
+ [ -x "$TMP/app/vendor/node-darwin-arm64/bin/node" ] || die "bundle missing vendored node"
49
+
50
+ mkdir -p "$STATE_DIR"
51
+ rm -rf "$APP_DIR.new"
52
+ mv "$TMP/app" "$APP_DIR.new"
53
+ if [ -d "$APP_DIR" ]; then rm -rf "$APP_DIR.old" && mv "$APP_DIR" "$APP_DIR.old"; fi
54
+ mv "$APP_DIR.new" "$APP_DIR"
55
+ rm -rf "$APP_DIR.old"
56
+ log "installed bundle to $APP_DIR"
57
+
58
+ NODE="$APP_DIR/vendor/node-darwin-arm64/bin/node"
59
+ SERVER="$APP_DIR/dist/index.js"
60
+ SAFE_PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
61
+
62
+ # 2. Which host is driving this install?
63
+ HOST="${S4L_INSTALL_HOST:-}"
64
+ if [ -z "$HOST" ]; then
65
+ if [ -n "${CODEX_THREAD_ID:-}" ]; then HOST="codex"
66
+ elif [ -n "${CLAUDECODE:-}" ] || [ -n "${CLAUDE_CODE_ENTRYPOINT:-}" ]; then HOST="claude"
67
+ fi
68
+ fi
69
+
70
+ HAVE_CODEX=0; [ -x "$CODEX_BIN" ] && HAVE_CODEX=1
71
+ HAVE_CLAUDE=0; command -v claude >/dev/null 2>&1 && HAVE_CLAUDE=1
72
+ [ "$HAVE_CODEX" = "1" ] || [ "$HAVE_CLAUDE" = "1" ] || die "neither the ChatGPT app nor the claude CLI was found; install one first (or double-click the .mcpb for Claude Desktop)."
73
+
74
+ # 3. Register the MCP server with every host present (registration is cheap and
75
+ # idempotent; the PROVIDER below is what decides who answers drafting turns).
76
+ if [ "$HAVE_CODEX" = "1" ]; then
77
+ "$CODEX_BIN" mcp remove s4l >/dev/null 2>&1 || true
78
+ "$CODEX_BIN" mcp add s4l --env "S4L_HOST=codex" --env "PATH=$SAFE_PATH" -- "$NODE" "$SERVER" \
79
+ && log "registered MCP server with the ChatGPT app (codex)" \
80
+ || log "WARN: codex mcp add failed; register manually with: codex mcp add s4l --env S4L_HOST=codex -- $NODE $SERVER"
81
+ fi
82
+ if [ "$HAVE_CLAUDE" = "1" ]; then
83
+ claude mcp remove -s user s4l >/dev/null 2>&1 || true
84
+ claude mcp add -s user s4l --env "PATH=$SAFE_PATH" -- "$NODE" "$SERVER" \
85
+ && log "registered MCP server with Claude Code" \
86
+ || log "WARN: claude mcp add failed; register manually with: claude mcp add -s user s4l -- $NODE $SERVER"
87
+ fi
88
+
89
+ # 4. Stamp the draft provider (single source of truth for which engine answers
90
+ # drafting turns; see scripts/draft_provider.py in the bundle).
91
+ PROVIDER=""
92
+ case "$HOST" in
93
+ codex) PROVIDER="codex-exec" ;;
94
+ claude) PROVIDER="claude-p" ;;
95
+ *) if [ "$HAVE_CLAUDE" = "1" ]; then PROVIDER="claude-p"; else PROVIDER="codex-exec"; fi ;;
96
+ esac
97
+ /usr/bin/python3 - "$STATE_DIR" "$PROVIDER" <<'PY'
98
+ import json, os, sys, time
99
+ sd, provider = sys.argv[1], sys.argv[2]
100
+ p = os.path.join(sd, "draft-provider.json")
101
+ tmp = p + ".tmp"
102
+ with open(tmp, "w") as f:
103
+ json.dump({"provider": provider,
104
+ "set_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
105
+ "set_by": "install.sh"}, f, indent=2)
106
+ os.replace(tmp, p)
107
+ PY
108
+ log "draft provider: $PROVIDER"
109
+
110
+ log "done."
111
+ echo
112
+ echo "Next steps:"
113
+ if [ "$HOST" = "codex" ] || { [ -z "$HOST" ] && [ "$HAVE_CODEX" = "1" ]; }; then
114
+ echo " 1. Fully quit and reopen the ChatGPT app so it picks up the new S4L connector."
115
+ echo " 2. In a new chat, say: Set up S4L for my product."
116
+ else
117
+ echo " 1. Restart your Claude session so it picks up the new S4L MCP server."
118
+ echo " 2. In a new chat, say: Set up S4L for my product."
119
+ fi
@@ -7,9 +7,12 @@ reddit-agent profile cookies (refreshed by bootstrap_reddit_cookies.py),
7
7
  inserts new rows into `replies`, and immediately fires engage_reddit.py
8
8
  with --limit so the loop runs end-to-end every 5 min.
9
9
 
10
- Inbox cannot tell us depth/parent_reply_id (it shows comment-replies and
11
- post-replies identically). We insert depth=1 / parent_reply_id=NULL; the
12
- engage step reads the live thread URL anyway.
10
+ Thread matching is two-stage: first against `posts` (threads we submitted),
11
+ then via the item's parent_id (t1_<our_comment_id>) against
12
+ replies.our_reply_id (replies to our comments on other people's threads).
13
+ The second stage inserts a followup row (parent_reply_id + depth); without
14
+ it every comment-chain reply is dropped as unmatched_thread, which orphaned
15
+ all comment-chain conversations between 2026-04-17 and 2026-08-05.
13
16
 
14
17
  Items older than BACKFILL_HOURS that aren't already in the DB are marked
15
18
  status='skipped' / skip_reason='backfill_old' so they show in the
@@ -50,11 +53,12 @@ ENGAGE_SCRIPT = os.path.expanduser("~/social-autoposter/scripts/engage_reddit.py
50
53
  INBOX_URL = "https://old.reddit.com/message/inbox/.json"
51
54
  PAGE_LIMIT = 100
52
55
  MAX_PAGES = 10 # caps pagination at ~1000 items; inbox retention is shorter than that anyway
53
- BACKFILL_HOURS = 48
56
+ BACKFILL_HOURS = int(os.environ.get("S4L_REDDIT_BACKFILL_HOURS", "48"))
54
57
  JITTER_MAX_SECS = 60
55
58
  PAGE_PAUSE_SECS = 1.5
56
- OWN_COMMENTS_PAGES = 20 # hard cap on pagination depth (max 2000 items)
57
- OWN_COMMENTS_LOOKBACK_DAYS = 30 # stop once we pass this many days back
59
+ OWN_COMMENTS_PAGES = 20 # hard cap on pagination depth (reddit caps listings at ~1000 items)
60
+ OWN_COMMENTS_LOOKBACK_DAYS = 90 # stop once we pass this many days back; replies to
61
+ # comments older than the (30-day) old value were dropped as unmatched_thread
58
62
 
59
63
  THREAD_ID_RE = re.compile(r"/comments/([a-z0-9]+)/")
60
64
 
@@ -97,12 +101,20 @@ class SessionInvalidError(Exception):
97
101
 
98
102
  def fetch_own_replies(reddit_account, cookie_header, user_agent,
99
103
  pages=OWN_COMMENTS_PAGES, lookback_days=OWN_COMMENTS_LOOKBACK_DAYS):
100
- """Build {parent_comment_id: {reply_id, reply_url, reply_content, replied_at}}
101
- by paging /user/<account>/comments.json. Used to detect comments the account
102
- already replied to outside the pipeline (e.g., manual browser replies).
103
- Stops when a page's oldest comment is older than lookback_days, or after
104
- `pages` pages, whichever comes first."""
104
+ """Build two maps by paging /user/<account>/comments.json:
105
+
106
+ 1. {parent_comment_id: {reply_id, reply_url, reply_content, replied_at}}
107
+ Used to detect comments the account already replied to outside the
108
+ pipeline (e.g., manual browser replies).
109
+ 2. {our_comment_id: parent_comment_id} for every comment of ours whose
110
+ parent is a comment (t1_). Used by the comment-chain matcher: an inbox
111
+ reply's parent_id is OUR comment, and OUR comment's parent is the
112
+ their_comment_id recorded on the replies row we posted from.
113
+
114
+ Returns (map1, map2). Stops when a page's oldest comment is older than
115
+ lookback_days, or after `pages` pages, whichever comes first."""
105
116
  out = {}
117
+ own_parents = {}
106
118
  after = None
107
119
  cutoff = time.time() - lookback_days * 86400
108
120
  url_base = f"https://old.reddit.com/user/{reddit_account}/comments/.json?limit={PAGE_LIMIT}"
@@ -114,11 +126,11 @@ def fetch_own_replies(reddit_account, cookie_header, user_agent,
114
126
  try:
115
127
  with urllib.request.urlopen(req, timeout=20) as resp:
116
128
  if "application/json" not in resp.headers.get("Content-Type", ""):
117
- return out # non-fatal; just skip the map
129
+ return out, own_parents # non-fatal; just skip the map
118
130
  data = json.loads(resp.read()).get("data", {})
119
131
  except Exception as e:
120
132
  print(f" own-replies fetch failed on page {page+1}: {e}")
121
- return out
133
+ return out, own_parents
122
134
  children = data.get("children", []) or []
123
135
  oldest_on_page = 0
124
136
  for c in children:
@@ -130,6 +142,9 @@ def fetch_own_replies(reddit_account, cookie_header, user_agent,
130
142
  if not parent.startswith("t1_"):
131
143
  continue # only comment-parents; post-parents handled via inbox matching
132
144
  parent_id = parent.removeprefix("t1_")
145
+ our_id = d.get("id")
146
+ if our_id and our_id not in own_parents:
147
+ own_parents[our_id] = parent_id
133
148
  if parent_id in out:
134
149
  continue
135
150
  reply_id = d.get("id")
@@ -146,12 +161,12 @@ def fetch_own_replies(reddit_account, cookie_header, user_agent,
146
161
  if oldest_on_page and oldest_on_page < cutoff:
147
162
  break # we've reached lookback horizon
148
163
  time.sleep(PAGE_PAUSE_SECS)
149
- return out
164
+ return out, own_parents
150
165
 
151
166
 
152
167
  class InboxScanner:
153
168
  def __init__(self, reddit_account, user_agent, cookie_header, excluded_authors=None,
154
- own_replies_map=None):
169
+ own_replies_map=None, own_comment_parents=None):
155
170
  # No DB handle anymore — every read/write hits the API. The `db` field
156
171
  # is kept for back-compat with `_insert_reply(self.db, ...)` callers
157
172
  # (they pass it through unchanged); the helper itself ignores the value.
@@ -163,10 +178,14 @@ class InboxScanner:
163
178
  self.excluded = {a.lower() for a in (excluded_authors or set())}
164
179
  self.excluded.update({"automoderator", "[deleted]", self.reddit_account_lower})
165
180
  self.own_replies_map = own_replies_map or {}
181
+ self.own_comment_parents = own_comment_parents or {}
166
182
  # Cache thread_id -> post_id lookups across a single scan so we don't
167
183
  # hit /api/v1/posts once per inbox entry (the same thread often
168
184
  # appears multiple times in a single page).
169
185
  self._post_id_cache = {}
186
+ # Cache our_reply_id -> parent replies row for comment-chain matching.
187
+ self._parent_reply_cache = {}
188
+ self.parent_matched = 0
170
189
  self.discovered = 0
171
190
  self.skipped_old = 0
172
191
  self.skipped_other = 0
@@ -199,10 +218,59 @@ class InboxScanner:
199
218
  break
200
219
  except Exception:
201
220
  post_id = None
221
+ if post_id is None:
222
+ # Exact fallback for posts older than the 500-row window (replies
223
+ # keep arriving on months-old submissions).
224
+ try:
225
+ resp = api_get(
226
+ "/api/v1/posts",
227
+ query={"platform": "reddit", "limit": 1,
228
+ "thread_url_contains": f"/comments/{thread_id}/"},
229
+ )
230
+ posts = ((resp or {}).get("data") or {}).get("posts") or []
231
+ if posts:
232
+ post_id = int(posts[0].get("id"))
233
+ except Exception:
234
+ post_id = None
202
235
  self._post_id_cache[thread_id] = post_id
203
236
  return post_id
204
237
 
205
- def _insert(self, post_id, comment_id, author, content, comment_url, status, skip_reason=None):
238
+ def _parent_reply_for_item(self, d):
239
+ """Match a comment_reply inbox item to OUR earlier comment on someone
240
+ else's thread. The posts-table match above only covers threads WE
241
+ submitted; most engagement is comments on other people's threads.
242
+ The DB does not store our comments' reddit ids (our_reply_id is
243
+ NULL on virtually all reddit rows), so the linkage goes through the
244
+ grandparent: the inbox item's parent_id is OUR comment; our own
245
+ comments listing (own_comment_parents) gives that comment's parent,
246
+ which is exactly the their_comment_id recorded on the replies row we
247
+ posted from. Returns that row (dict with id/post_id/depth) or None."""
248
+ parent = d.get("parent_id") or ""
249
+ if not parent.startswith("t1_"):
250
+ return None
251
+ our_id = parent.removeprefix("t1_")
252
+ grandparent_id = self.own_comment_parents.get(our_id)
253
+ if not grandparent_id:
254
+ return None
255
+ if grandparent_id in self._parent_reply_cache:
256
+ return self._parent_reply_cache[grandparent_id]
257
+ row = None
258
+ try:
259
+ resp = api_get(
260
+ "/api/v1/replies",
261
+ query={"platform": "reddit", "their_comment_id": grandparent_id,
262
+ "limit": 1},
263
+ )
264
+ rows = ((resp or {}).get("data") or {}).get("replies") or []
265
+ if rows:
266
+ row = rows[0]
267
+ except Exception:
268
+ row = None
269
+ self._parent_reply_cache[grandparent_id] = row
270
+ return row
271
+
272
+ def _insert(self, post_id, comment_id, author, content, comment_url, status, skip_reason=None,
273
+ parent_reply_id=None, depth=1):
206
274
  override = self.own_replies_map.get(comment_id)
207
275
  if override:
208
276
  from datetime import datetime, timezone
@@ -210,7 +278,7 @@ class InboxScanner:
210
278
  replied_at = datetime.fromtimestamp(ts, tz=timezone.utc) if ts else None
211
279
  result = _insert_reply(
212
280
  self.db, post_id, "reddit", comment_id, author, content, comment_url,
213
- parent_reply_id=None, depth=1, status="replied", skip_reason=None,
281
+ parent_reply_id=parent_reply_id, depth=depth, status="replied", skip_reason=None,
214
282
  our_reply_id=override.get("our_reply_id"),
215
283
  our_reply_content=override.get("our_reply_content"),
216
284
  our_reply_url=override.get("our_reply_url"),
@@ -221,7 +289,7 @@ class InboxScanner:
221
289
  return
222
290
  result = _insert_reply(
223
291
  self.db, post_id, "reddit", comment_id, author, content, comment_url,
224
- parent_reply_id=None, depth=1, status=status, skip_reason=skip_reason,
292
+ parent_reply_id=parent_reply_id, depth=depth, status=status, skip_reason=skip_reason,
225
293
  )
226
294
  if result == "pending":
227
295
  self.discovered += 1
@@ -246,21 +314,32 @@ class InboxScanner:
246
314
  if not comment_id:
247
315
  continue
248
316
  author = d.get("author") or "[deleted]"
249
- if author.lower() in self.excluded:
317
+ if author.lower() in self.excluded or author.lower().endswith("-modteam"):
318
+ # *-ModTeam items are removal notices, not conversations;
319
+ # the strike pipeline tracks removals separately.
250
320
  self.skipped_other += 1
251
321
  continue
252
322
  context = d.get("context") or ""
253
323
  post_id = self._post_id_for_context(context)
324
+ parent_reply_id = None
325
+ depth = 1
254
326
  if not post_id:
255
- self.unmatched += 1
256
- continue
327
+ parent = self._parent_reply_for_item(d)
328
+ if not parent:
329
+ self.unmatched += 1
330
+ continue
331
+ self.parent_matched += 1
332
+ post_id = parent.get("post_id") # nullable; next-pending LEFT JOINs posts
333
+ parent_reply_id = parent.get("id")
334
+ depth = (parent.get("depth") or 1) + 1
257
335
  comment_url = "https://old.reddit.com" + context.split("?")[0]
258
336
  content = d.get("body") or ""
259
337
  created = float(d.get("created_utc") or 0)
260
338
  if created and created < backfill_cutoff:
261
339
  pre = self.discovered + self.skipped_old
262
340
  self._insert(post_id, comment_id, author, content, comment_url,
263
- status="skipped", skip_reason="backfill_old")
341
+ status="skipped", skip_reason="backfill_old",
342
+ parent_reply_id=parent_reply_id, depth=depth)
264
343
  if (self.discovered + self.skipped_old) == pre:
265
344
  consecutive_known += 1
266
345
  else:
@@ -268,7 +347,8 @@ class InboxScanner:
268
347
  else:
269
348
  pre = self.discovered
270
349
  self._insert(post_id, comment_id, author, content, comment_url,
271
- status="pending")
350
+ status="pending",
351
+ parent_reply_id=parent_reply_id, depth=depth)
272
352
  if self.discovered == pre:
273
353
  consecutive_known += 1
274
354
  else:
@@ -293,7 +373,8 @@ class InboxScanner:
293
373
  f"Inbox scan complete: seen={self.total_seen} "
294
374
  f"new_pending={self.discovered} backfill_skipped={self.skipped_old} "
295
375
  f"already_replied={self.already_replied} "
296
- f"excluded_author={self.skipped_other} unmatched_thread={self.unmatched}"
376
+ f"excluded_author={self.skipped_other} unmatched_thread={self.unmatched} "
377
+ f"parent_matched={self.parent_matched}"
297
378
  )
298
379
  return {
299
380
  "discovered": self.discovered,
@@ -301,6 +382,7 @@ class InboxScanner:
301
382
  "already_replied": self.already_replied,
302
383
  "excluded": self.skipped_other,
303
384
  "unmatched": self.unmatched,
385
+ "parent_matched": self.parent_matched,
304
386
  "total_seen": self.total_seen,
305
387
  }
306
388
 
@@ -349,11 +431,13 @@ def main():
349
431
 
350
432
  user_agent = f"social-autoposter/1.0 (u/{reddit_account} inbox-scan)"
351
433
  excluded_authors = {a for a in config.get("exclusions", {}).get("authors", [])}
352
- own_replies_map = fetch_own_replies(reddit_account, cookie_header, user_agent)
353
- print(f"Own-replies map: {len(own_replies_map)} parent comment_ids we've already replied to")
434
+ own_replies_map, own_comment_parents = fetch_own_replies(reddit_account, cookie_header, user_agent)
435
+ print(f"Own-replies map: {len(own_replies_map)} parent comment_ids we've already replied to; "
436
+ f"{len(own_comment_parents)} own comments mapped for chain matching")
354
437
  scanner = InboxScanner(reddit_account, user_agent, cookie_header,
355
438
  excluded_authors=excluded_authors,
356
- own_replies_map=own_replies_map)
439
+ own_replies_map=own_replies_map,
440
+ own_comment_parents=own_comment_parents)
357
441
  try:
358
442
  scanner.scan()
359
443
  except SessionInvalidError as e:
@@ -221,6 +221,22 @@ def compute(glob_pattern: str | None = None) -> str:
221
221
  the module docstring for why. Pass an explicit glob_pattern to scan a
222
222
  literal pattern instead (e.g. a test fixture dir); this bypasses account
223
223
  scoping entirely, matching the old unconditional behavior."""
224
+ # Provider layer (2026-08-06): when the box's draft provider is claude-p or
225
+ # codex-exec (scripts/draft_provider.py), drafting runs inline in the
226
+ # launchd cycle itself; NO Claude Desktop worker task is required, so the
227
+ # registry scan below is irrelevant and its 'missing' verdict would be a
228
+ # false alarm on every surface (menubar warning, dashboard "Set up draft
229
+ # schedule", Sentry). Health for those providers is watched by the queue
230
+ # drain-latency rails, which the inline path already stamps.
231
+ if glob_pattern is None:
232
+ try:
233
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
234
+ import draft_provider # noqa: E402
235
+
236
+ if draft_provider.get() != "claude-desktop-queue":
237
+ return "ok"
238
+ except Exception:
239
+ pass
224
240
  patterns = [glob_pattern] if glob_pattern is not None else _active_registry_glob_patterns()
225
241
  newest_epoch, newest_enabled = None, False
226
242
  # Track the freshest just-created, enabled, never-yet-fired task so a schedule
@@ -89,6 +89,9 @@ try:
89
89
  _classify_import_error,
90
90
  _is_headless,
91
91
  _keychain_safe_storage_ok,
92
+ _keychain_declined,
93
+ _keychain_mark_declined,
94
+ _keychain_clear_declined,
92
95
  )
93
96
  except Exception: # pragma: no cover - defensive
94
97
  def _classify_import_error(detail): # type: ignore[misc]
@@ -100,6 +103,15 @@ except Exception: # pragma: no cover - defensive
100
103
  def _keychain_safe_storage_ok(browser_label="Chrome"): # type: ignore[misc]
101
104
  return True, "probe unavailable"
102
105
 
106
+ def _keychain_declined(): # type: ignore[misc]
107
+ return None
108
+
109
+ def _keychain_mark_declined(source, detail): # type: ignore[misc]
110
+ pass
111
+
112
+ def _keychain_clear_declined(): # type: ignore[misc]
113
+ pass
114
+
103
115
  # Vendored cookie copier - stdlib-only browser/profile detection plus the
104
116
  # actual keychain-decrypt + CDP-inject copy. Same module connect_x uses,
105
117
  # parameterized here by --domains reddit.com.
@@ -752,31 +764,19 @@ def cmd_connect(args) -> dict:
752
764
  manual_login = bool(getattr(args, "manual_login", False))
753
765
  will_import = has_importable and not manual_login
754
766
 
755
- # 1c. Headless keychain preflight, import path only (manual login reads no
756
- # keychain and must never be blocked by this).
757
- if will_import and _is_headless():
758
- kc_ok, kc_detail = _keychain_safe_storage_ok("Chrome")
759
- if not kc_ok:
760
- return {
761
- "ok": True,
762
- "connected": False,
763
- "state": "keychain_locked",
764
- "error_type": "keychain_locked",
765
- "headless": True,
766
- "keychain_detail": kc_detail,
767
- "note": (
768
- "Cookie import requires reading the browser's Safe Storage from "
769
- "the macOS Keychain, but this process can't access it (probably "
770
- "running over SSH or another headless context). Run this once in "
771
- "the same session:\n"
772
- " security unlock-keychain ~/Library/Keychains/login.keychain-db\n"
773
- "Then re-run connect_reddit. Or connect Reddit with manual login "
774
- "instead; that signs in directly in the autoposter's own browser "
775
- "and needs no keychain."
776
- ),
777
- "remediation_cmd": "security unlock-keychain ~/Library/Keychains/login.keychain-db",
778
- "cdp": CDP,
779
- }
767
+ # 1c. Keychain prompt-once gate (2026-08-06; replaces the headless probe —
768
+ # see setup_twitter_auth.py for the full rationale). The user is asked for
769
+ # keychain access at most ONCE across ALL platforms: the import attempt
770
+ # itself is the single prompt, and a Deny/Cancel recorded by either the X
771
+ # or the reddit importer permanently routes both to manual login.
772
+ if will_import:
773
+ if getattr(args, "retry_keychain_import", False):
774
+ _keychain_clear_declined()
775
+ keychain_declined = _keychain_declined()
776
+ if keychain_declined:
777
+ will_import = False
778
+ else:
779
+ keychain_declined = None
780
780
 
781
781
  # 2. Import from the user's everyday browser.
782
782
  attempts = []
@@ -791,6 +791,12 @@ def cmd_connect(args) -> dict:
791
791
  "detail": detail,
792
792
  "error_type": error_type,
793
793
  })
794
+ if error_type == "keychain_acl_denied":
795
+ # The user answered the ONE allowed keychain prompt with
796
+ # Deny/Cancel. Record it (shared latch with connect_x) and stop
797
+ # the sweep — the next source would fire another prompt.
798
+ _keychain_mark_declined(src, detail)
799
+ break
794
800
  if not res.get("ok"):
795
801
  continue
796
802
  fields = _validate_session()
@@ -816,10 +822,13 @@ def cmd_connect(args) -> dict:
816
822
  rolled_up_error_type = (
817
823
  next(iter(distinct_error_types)) if len(distinct_error_types) == 1 else None
818
824
  )
825
+ # A Deny/Cancel on THIS run's prompt, regardless of what other sources
826
+ # failed with (the single-type roll-up above misses mixed-error runs).
827
+ denied_now = any(a.get("error_type") == "keychain_acl_denied" for a in attempts)
819
828
  open_login = (
820
829
  manual_login
821
830
  or not will_import
822
- or rolled_up_error_type == "keychain_acl_denied"
831
+ or denied_now
823
832
  )
824
833
 
825
834
  shown = False
@@ -834,19 +843,31 @@ def cmd_connect(args) -> dict:
834
843
  note="You logged in manually; the autoposter detected the live "
835
844
  "reddit session in its own browser profile.")
836
845
 
837
- if rolled_up_error_type == "keychain_acl_denied":
846
+ if denied_now:
847
+ # Prompt-once policy: the Deny/Cancel is latched; never suggest
848
+ # re-running the import — that advice made setup agents loop and
849
+ # re-prompt. Manual login is the path now.
850
+ note = (
851
+ "You declined the macOS Keychain prompt, so automatic import is off — "
852
+ "we only ask once and won't show that prompt again. "
853
+ "A Chrome window is open at the Reddit login page"
854
+ + ("" if shown else " (look for a 'Google Chrome' window)")
855
+ + "; log in there by hand and ask me to re-check. Do NOT re-run "
856
+ "the cookie import. (Auto-import tried: " + ", ".join(sources) + ".)"
857
+ )
858
+ extra = {"remediation": "use_manual_login", "keychain_prompt": "declined_latched"}
859
+ elif keychain_declined:
860
+ # An earlier run (X or reddit) already recorded the Deny/Cancel; this
861
+ # call skipped the import entirely and opened the manual-login window.
838
862
  note = (
839
- "It looks like you clicked Deny (or Cancel) on the macOS Keychain prompt. "
840
- "To import your Reddit session automatically, the autoposter needs to read "
841
- "your browser's \"Safe Storage\" key from your Keychain. Re-run "
842
- "connect_reddit and click Allow (or Always Allow) and the import will "
843
- "finish on its own. If you'd rather not grant keychain access, there's "
844
- "already a Chrome window open at the Reddit login page"
863
+ "You previously declined the macOS Keychain prompt, so I skipped the "
864
+ "automatic import (we only ask once). A Chrome window is open at the "
865
+ "Reddit login page"
845
866
  + ("" if shown else " (look for a 'Google Chrome' window)")
846
- + "; just log in there by hand and ask me to re-check. "
847
- "(Auto-import tried: " + ", ".join(sources) + ".)"
867
+ + "; log in there by hand and ask me to re-check. Do NOT re-run "
868
+ "the cookie import; it will not prompt again."
848
869
  )
849
- extra = {"remediation": "rerun_connect_reddit_and_click_allow"}
870
+ extra = {"remediation": "use_manual_login", "keychain_prompt": "declined_latched"}
850
871
  elif open_login:
851
872
  _why = (
852
873
  "" if will_import
@@ -907,6 +928,11 @@ def main() -> int:
907
928
  c.add_argument("--login-wait", type=float, default=300.0,
908
929
  help="Seconds to wait for a MANUAL login to complete before "
909
930
  "returning needs_login (default 300; 0 disables the wait).")
931
+ c.add_argument("--retry-keychain-import", action="store_true",
932
+ help="Clear the shared keychain-declined latch so the import path "
933
+ "may show the macOS keychain prompt ONE more time. The prompt "
934
+ "is once-only by policy; pass this only when the user "
935
+ "explicitly asks to retry the automatic import.")
910
936
  args = ap.parse_args()
911
937
 
912
938
  if args.cmd == "detect-sources":