delimit-cli 3.13.2 → 3.14.0

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,504 @@
1
+ """Automated social media — authentic engagement at scale.
2
+
3
+ Posts are value-first: tips, changelogs, governance insights.
4
+ Never spam, never generic marketing. Every post teaches something.
5
+
6
+ Multi-account support: credentials stored per handle in
7
+ ~/.delimit/secrets/twitter-<handle>.json (e.g. twitter-delimit_ai.json).
8
+ Legacy twitter-full.json is treated as the default account.
9
+ """
10
+ import json
11
+ import logging
12
+ import os
13
+ import random
14
+ import uuid
15
+ from pathlib import Path
16
+ from datetime import datetime, timezone
17
+
18
+ logger = logging.getLogger("delimit.ai.social")
19
+
20
+ SECRETS_DIR = Path.home() / ".delimit" / "secrets"
21
+ SOCIAL_LOG = Path.home() / ".delimit" / "social_log.jsonl"
22
+ DRAFTS_FILE = Path.home() / ".delimit" / "social_drafts.jsonl"
23
+ ACCOUNTS_FILE = SECRETS_DIR / "twitter-accounts.json"
24
+
25
+ # Platform-specific tone guidelines for draft generation
26
+ # Used by AI agents when drafting content — included in draft metadata
27
+ # so the agent (or human reviewer) can adjust tone before posting.
28
+ PLATFORM_TONE = {
29
+ "twitter": {
30
+ "max_length": 280,
31
+ "tone": "concise, technical, value-first",
32
+ "rules": [
33
+ "Lead with the insight, not the product",
34
+ "One clear point per tweet",
35
+ "Include a link or install command when relevant",
36
+ ],
37
+ },
38
+ "reddit": {
39
+ "max_length": 500,
40
+ "tone": "casual, opinionated, human",
41
+ "rules": [
42
+ "NEVER use bullet points or numbered lists — instant AI tell",
43
+ "Keep it to 2-3 sentences max, not multi-paragraph essays",
44
+ "Write like you're typing on your phone, not writing a blog post",
45
+ "Be opinionated — 'honestly X drives me nuts' not 'X is a known issue'",
46
+ "Use contractions, sentence fragments, rough edges",
47
+ "One sharp point per comment — don't cover three topics",
48
+ "Personal experience framing — 'we hit this exact problem' not 'teams often encounter'",
49
+ "A typo or two is fine — too polished = flagged as AI by mods",
50
+ "NEVER structure responses with headers, bold text, or formatted lists",
51
+ "r/devops mods actively flag LLM-generated content (learned 2026-03-27)",
52
+ ],
53
+ },
54
+ "linkedin": {
55
+ "max_length": 1300,
56
+ "tone": "professional, concise, insight-driven",
57
+ "rules": [
58
+ "Lead with a hook question or surprising stat",
59
+ "Keep paragraphs to 1-2 lines",
60
+ "End with a clear CTA or question",
61
+ ],
62
+ },
63
+ }
64
+
65
+ # Content templates — each provides genuine value
66
+ CONTENT_TEMPLATES = {
67
+ "tip": [
68
+ "Tip: You can detect {count} types of breaking API changes with one line of YAML:\n\n- uses: delimit-ai/delimit-action@v1\n with:\n spec: api/openapi.yaml\n\nNo config needed. Advisory mode by default.",
69
+ "Did you know? When you switch from Claude Code to Codex, you lose all context. With a shared ledger, say \"what's on the ledger?\" in any assistant and pick up exactly where you left off.",
70
+ "API governance tip: The 3 most common breaking changes we catch:\n\n1. Endpoint removed without deprecation\n2. Required field added to request body\n3. Response field type changed\n\nAll detectable before merge.",
71
+ "Quick tip: Run `npx delimit-cli doctor` in any project to check your governance setup. It checks for policies, specs, workflows, and git config in seconds.",
72
+ "Pro tip: Use policy presets to match your team's risk tolerance:\n\n{bullet} strict — all violations are errors\n{bullet} default — balanced\n{bullet} relaxed — warnings only\n\n`npx delimit-cli init --preset strict`",
73
+ ],
74
+ "changelog": [
75
+ "Just shipped: {feature}\n\n{detail}\n\nUpdate: npx delimit-cli@latest setup",
76
+ ],
77
+ "insight": [
78
+ "We analyzed {count} API changes this week. {percent}% were breaking. The most common? {top_change}.\n\nAutomate this check: delimit.ai",
79
+ "Hot take: In 2 years, unmanaged AI agents touching production code will be as unacceptable as unmanaged SSH keys.\n\nGovernance isn't optional. It's infrastructure.",
80
+ "The problem with AI coding assistants isn't capability — it's context loss. Every time you switch models, you start from zero. That's the real productivity killer.",
81
+ ],
82
+ "engagement": [
83
+ "What's the worst API breaking change you've shipped to production? We've seen some creative ones.",
84
+ "How many AI coding assistants does your team use? We're seeing teams average 2-3, with context scattered across all of them.",
85
+ "What's your API governance process today? Manual review? CI check? Nothing? (No judgment — that's why we built this.)",
86
+ ],
87
+ }
88
+
89
+
90
+ def _resolve_creds_path(account: str = "") -> Path | None:
91
+ """Resolve credentials file for a given account handle.
92
+
93
+ Lookup order:
94
+ 1. ~/.delimit/secrets/twitter-<account>.json (per-handle)
95
+ 2. ~/.delimit/secrets/twitter-full.json (legacy default)
96
+ """
97
+ if account:
98
+ per_handle = SECRETS_DIR / f"twitter-{account}.json"
99
+ if per_handle.exists():
100
+ return per_handle
101
+ # Legacy fallback
102
+ legacy = SECRETS_DIR / "twitter-full.json"
103
+ if legacy.exists():
104
+ return legacy
105
+ return None
106
+
107
+
108
+ def get_twitter_client(account: str = ""):
109
+ """Get authenticated Twitter client via tweepy for a specific account.
110
+
111
+ Returns:
112
+ Tuple of (client, handle, error). On success error is None.
113
+ On failure client and handle are None and error is a non-empty string
114
+ that distinguishes between "not configured" and "auth failed".
115
+
116
+ Args:
117
+ account: Twitter handle (without @). Empty string = default account.
118
+ """
119
+ acct_label = account or "default"
120
+ creds_path = _resolve_creds_path(account)
121
+ if not creds_path:
122
+ configured = list_twitter_accounts()
123
+ if configured:
124
+ handles = [a["handle"] for a in configured]
125
+ return None, None, (
126
+ f"Account '{acct_label}' is not configured. "
127
+ f"Configured accounts: {handles}. "
128
+ f"Place credentials in ~/.delimit/secrets/twitter-{account}.json"
129
+ )
130
+ return None, None, (
131
+ f"No Twitter accounts configured. "
132
+ f"Place credentials in ~/.delimit/secrets/twitter-<handle>.json"
133
+ )
134
+ try:
135
+ import tweepy
136
+ creds = json.loads(creds_path.read_text())
137
+ client = tweepy.Client(
138
+ consumer_key=creds["consumer_key"],
139
+ consumer_secret=creds["consumer_secret"],
140
+ access_token=creds["access_token"],
141
+ access_token_secret=creds["access_token_secret"],
142
+ )
143
+ handle = creds.get("handle", account or "delimit_ai")
144
+ return client, handle, None
145
+ except KeyError as e:
146
+ msg = (
147
+ f"Account '{acct_label}' is configured ({creds_path.name}) "
148
+ f"but missing credential field {e}"
149
+ )
150
+ logger.error(msg)
151
+ return None, None, msg
152
+ except ImportError:
153
+ msg = "tweepy is not installed. Run: pip install tweepy"
154
+ logger.error(msg)
155
+ return None, None, msg
156
+ except json.JSONDecodeError as e:
157
+ msg = (
158
+ f"Account '{acct_label}' credentials file ({creds_path.name}) "
159
+ f"contains invalid JSON: {e}"
160
+ )
161
+ logger.error(msg)
162
+ return None, None, msg
163
+ except Exception as e:
164
+ msg = (
165
+ f"Account '{acct_label}' is configured ({creds_path.name}) "
166
+ f"but authentication failed: {e}"
167
+ )
168
+ logger.error(msg, exc_info=True)
169
+ return None, None, msg
170
+
171
+
172
+ def list_twitter_accounts() -> list[dict]:
173
+ """List all configured Twitter accounts, deduplicated by handle.
174
+
175
+ When multiple credential files resolve to the same handle,
176
+ the per-handle file (twitter-<handle>.json) wins over legacy files.
177
+ """
178
+ accounts = []
179
+ seen_handles: set[str] = set()
180
+ if not SECRETS_DIR.exists():
181
+ return accounts
182
+ for f in sorted(SECRETS_DIR.glob("twitter-*.json")):
183
+ name = f.stem # e.g. "twitter-delimit_ai"
184
+ if name == "twitter-accounts":
185
+ continue
186
+ # Skip legacy twitter-full.json in this pass (handled below)
187
+ if name == "twitter-full":
188
+ continue
189
+ try:
190
+ creds = json.loads(f.read_text())
191
+ handle = creds.get("handle", name.removeprefix("twitter-"))
192
+ if handle in seen_handles:
193
+ continue
194
+ seen_handles.add(handle)
195
+ accounts.append({"handle": handle, "file": f.name})
196
+ except (json.JSONDecodeError, ValueError):
197
+ pass
198
+ # Include legacy twitter-full.json only if its handle is not already covered
199
+ legacy = SECRETS_DIR / "twitter-full.json"
200
+ if legacy.exists():
201
+ try:
202
+ creds = json.loads(legacy.read_text())
203
+ handle = creds.get("handle", "default")
204
+ if handle not in seen_handles:
205
+ seen_handles.add(handle)
206
+ accounts.append({"handle": handle, "file": "twitter-full.json", "default": True})
207
+ except (json.JSONDecodeError, ValueError):
208
+ pass
209
+ return accounts
210
+
211
+
212
+ def post_tweet(text: str, account: str = "", quote_tweet_id: str = "",
213
+ reply_to_id: str = "") -> dict:
214
+ """Post a tweet via the Twitter API.
215
+
216
+ Args:
217
+ text: Tweet text content.
218
+ account: Twitter handle (without @) to post from. Empty = default.
219
+ quote_tweet_id: Tweet ID to quote. Creates a quote tweet.
220
+ reply_to_id: Tweet ID to reply to. Creates a reply.
221
+ """
222
+ client, handle, init_error = get_twitter_client(account)
223
+ if not client:
224
+ # Always return the specific error from get_twitter_client.
225
+ # Previous code fell through to a misleading "not found" message
226
+ # when init_error was empty, even though the account was configured.
227
+ if init_error:
228
+ return {"error": init_error}
229
+ # Fallback: should not be reachable, but be explicit
230
+ return {"error": f"Failed to initialize Twitter client for account '{account or 'default'}'. "
231
+ f"Check credentials in ~/.delimit/secrets/twitter-{account or 'full'}.json"}
232
+ try:
233
+ kwargs = {"text": text}
234
+ if quote_tweet_id:
235
+ kwargs["quote_tweet_id"] = quote_tweet_id
236
+ if reply_to_id:
237
+ kwargs["in_reply_to_tweet_id"] = reply_to_id
238
+ result = client.create_tweet(**kwargs)
239
+ tweet_id = result.data["id"]
240
+ log_post("twitter", text, tweet_id, handle=handle,
241
+ quote_tweet_id=quote_tweet_id, reply_to_id=reply_to_id)
242
+ return {
243
+ "posted": True,
244
+ "id": tweet_id,
245
+ "handle": handle,
246
+ "url": f"https://x.com/{handle}/status/{tweet_id}",
247
+ "type": "quote_tweet" if quote_tweet_id else "reply" if reply_to_id else "tweet",
248
+ }
249
+ except Exception as e:
250
+ return {"error": str(e), "handle": handle}
251
+
252
+
253
+ def generate_post(category: str = "", custom: str = "") -> dict:
254
+ """Generate a post. If custom is provided, use that. Otherwise pick from templates."""
255
+ if custom:
256
+ return {"text": custom, "category": "custom"}
257
+
258
+ if not category or category not in CONTENT_TEMPLATES:
259
+ category = random.choice(list(CONTENT_TEMPLATES.keys()))
260
+
261
+ templates = CONTENT_TEMPLATES[category]
262
+ template = random.choice(templates)
263
+
264
+ # Fill in template variables with realistic data
265
+ text = template.format(
266
+ count=27,
267
+ percent=random.randint(15, 35),
268
+ top_change=random.choice([
269
+ "endpoint removed",
270
+ "type changed",
271
+ "required field added",
272
+ ]),
273
+ feature="(specify feature name)",
274
+ detail="(specify feature details)",
275
+ bullet="\u2022",
276
+ )
277
+
278
+ return {"text": text, "category": category}
279
+
280
+
281
+ def get_post_history(limit: int = 20) -> list:
282
+ """Get recent post history from the JSONL log."""
283
+ if not SOCIAL_LOG.exists():
284
+ return []
285
+ posts = []
286
+ for line in reversed(SOCIAL_LOG.read_text().splitlines()):
287
+ if not line.strip():
288
+ continue
289
+ try:
290
+ posts.append(json.loads(line))
291
+ except (json.JSONDecodeError, ValueError):
292
+ pass
293
+ if len(posts) >= limit:
294
+ break
295
+ return posts
296
+
297
+
298
+ def log_post(platform: str, text: str, post_id: str = "", handle: str = "",
299
+ quote_tweet_id: str = "", reply_to_id: str = ""):
300
+ """Log a social media post to the JSONL log."""
301
+ SOCIAL_LOG.parent.mkdir(parents=True, exist_ok=True)
302
+ entry = {
303
+ "ts": datetime.now(timezone.utc).isoformat(),
304
+ "platform": platform,
305
+ "handle": handle,
306
+ "text": text[:200],
307
+ "post_id": post_id,
308
+ }
309
+ if quote_tweet_id:
310
+ entry["quote_tweet_id"] = quote_tweet_id
311
+ if reply_to_id:
312
+ entry["reply_to_id"] = reply_to_id
313
+ with open(SOCIAL_LOG, "a") as f:
314
+ f.write(json.dumps(entry) + "\n")
315
+
316
+
317
+ def should_post_today() -> bool:
318
+ """Check if we've hit the daily posting limit.
319
+
320
+ Limit is configurable via DELIMIT_DAILY_TWEETS env var (default 8).
321
+ Uses US Eastern Time for day boundaries since the posting schedule
322
+ targets 9am/3pm ET.
323
+ """
324
+ from zoneinfo import ZoneInfo
325
+
326
+ daily_limit = int(os.environ.get("DELIMIT_DAILY_TWEETS", "8"))
327
+ et_now = datetime.now(ZoneInfo("America/New_York"))
328
+ today_et = et_now.strftime("%Y-%m-%d")
329
+ history = get_post_history(100)
330
+ today_posts = []
331
+ for p in history:
332
+ ts_str = p.get("ts", "")
333
+ if ts_str:
334
+ try:
335
+ ts = datetime.fromisoformat(ts_str).astimezone(ZoneInfo("America/New_York"))
336
+ if ts.strftime("%Y-%m-%d") == today_et:
337
+ today_posts.append(p)
338
+ except (ValueError, TypeError):
339
+ continue
340
+ return len(today_posts) < daily_limit
341
+
342
+
343
+ # ═════════════════════════════════════════════════════════════════════
344
+ # DRAFT MODE — Queue content for review before posting
345
+ # ═════════════════════════════════════════════════════════════════════
346
+
347
+
348
+ def get_platform_tone(platform: str = "twitter") -> dict:
349
+ """Return tone guidelines for a platform.
350
+
351
+ AI agents should call this before drafting content to get
352
+ platform-specific rules for voice, length, and formatting.
353
+ """
354
+ return PLATFORM_TONE.get(platform, PLATFORM_TONE.get("twitter", {}))
355
+
356
+
357
+ def save_draft(text: str, platform: str = "twitter", account: str = "",
358
+ quote_tweet_id: str = "", reply_to_id: str = "") -> dict:
359
+ """Save a social media post as a draft for later approval.
360
+
361
+ Returns the draft entry with a unique draft_id and platform tone guidelines.
362
+ """
363
+ DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True)
364
+ draft_id = uuid.uuid4().hex[:12]
365
+ tone = get_platform_tone(platform)
366
+ entry = {
367
+ "draft_id": draft_id,
368
+ "text": text,
369
+ "platform": platform,
370
+ "account": account,
371
+ "quote_tweet_id": quote_tweet_id,
372
+ "reply_to_id": reply_to_id,
373
+ "status": "pending",
374
+ "timestamp": datetime.now(timezone.utc).isoformat(),
375
+ }
376
+ # Check tone violations
377
+ warnings = []
378
+ if tone.get("max_length") and len(text) > tone["max_length"]:
379
+ warnings.append(f"Text exceeds {platform} max length ({len(text)}/{tone['max_length']})")
380
+ if platform == "reddit":
381
+ if any(line.strip().startswith(("- ", "* ", "1.", "2.", "3.")) for line in text.split("\n")):
382
+ warnings.append("REDDIT WARNING: Contains bullet/numbered lists — high risk of mod removal as AI content")
383
+ if text.count("\n\n") >= 3:
384
+ warnings.append("REDDIT WARNING: Multi-paragraph essay format — shorten to 2-3 sentences")
385
+ if "**" in text:
386
+ warnings.append("REDDIT WARNING: Contains bold formatting — too polished for Reddit")
387
+ if warnings:
388
+ entry["tone_warnings"] = warnings
389
+ with open(DRAFTS_FILE, "a") as f:
390
+ f.write(json.dumps(entry) + "\n")
391
+ return entry
392
+
393
+
394
+ def store_draft_message_id(draft_id: str, message_id: str) -> bool:
395
+ """Store the outbound notification Message-ID on a draft record.
396
+
397
+ This enables In-Reply-To header matching for auto-approval via the
398
+ inbox polling daemon (Consensus 116).
399
+
400
+ Args:
401
+ draft_id: The 12-char hex draft ID.
402
+ message_id: The Message-ID header from the sent notification email.
403
+
404
+ Returns:
405
+ True if the draft was found and updated, False otherwise.
406
+ """
407
+ all_entries = _load_all_drafts()
408
+ for entry in all_entries:
409
+ if entry.get("draft_id") == draft_id:
410
+ entry["notification_message_id"] = message_id
411
+ _rewrite_drafts(all_entries)
412
+ return True
413
+ return False
414
+
415
+
416
+ def list_drafts(status: str = "pending") -> list[dict]:
417
+ """List drafts filtered by status (pending, approved, rejected)."""
418
+ if not DRAFTS_FILE.exists():
419
+ return []
420
+ drafts = []
421
+ for line in DRAFTS_FILE.read_text().splitlines():
422
+ if not line.strip():
423
+ continue
424
+ try:
425
+ entry = json.loads(line)
426
+ if entry.get("status") == status:
427
+ drafts.append(entry)
428
+ except (json.JSONDecodeError, ValueError):
429
+ pass
430
+ return drafts
431
+
432
+
433
+ def _rewrite_drafts(all_entries: list[dict]) -> None:
434
+ """Rewrite the drafts file with updated entries."""
435
+ DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True)
436
+ with open(DRAFTS_FILE, "w") as f:
437
+ for entry in all_entries:
438
+ f.write(json.dumps(entry) + "\n")
439
+
440
+
441
+ def _load_all_drafts() -> list[dict]:
442
+ """Load all draft entries from the JSONL file."""
443
+ if not DRAFTS_FILE.exists():
444
+ return []
445
+ entries = []
446
+ for line in DRAFTS_FILE.read_text().splitlines():
447
+ if not line.strip():
448
+ continue
449
+ try:
450
+ entries.append(json.loads(line))
451
+ except (json.JSONDecodeError, ValueError):
452
+ pass
453
+ return entries
454
+
455
+
456
+ def approve_draft(draft_id: str) -> dict:
457
+ """Approve a draft and post it. Returns the post result."""
458
+ all_entries = _load_all_drafts()
459
+ target = None
460
+ for entry in all_entries:
461
+ if entry.get("draft_id") == draft_id:
462
+ target = entry
463
+ break
464
+ if not target:
465
+ return {"error": f"Draft '{draft_id}' not found"}
466
+ if target.get("status") != "pending":
467
+ return {"error": f"Draft '{draft_id}' is already {target.get('status')}"}
468
+
469
+ # Post it
470
+ result = post_tweet(
471
+ target["text"],
472
+ account=target.get("account", ""),
473
+ quote_tweet_id=target.get("quote_tweet_id", ""),
474
+ reply_to_id=target.get("reply_to_id", ""),
475
+ )
476
+
477
+ if "error" in result:
478
+ return result
479
+
480
+ # Update status
481
+ target["status"] = "approved"
482
+ target["approved_at"] = datetime.now(timezone.utc).isoformat()
483
+ target["post_result"] = result
484
+ _rewrite_drafts(all_entries)
485
+ return {"draft_id": draft_id, "status": "approved", "post_result": result}
486
+
487
+
488
+ def reject_draft(draft_id: str) -> dict:
489
+ """Reject a draft. It will not be posted."""
490
+ all_entries = _load_all_drafts()
491
+ target = None
492
+ for entry in all_entries:
493
+ if entry.get("draft_id") == draft_id:
494
+ target = entry
495
+ break
496
+ if not target:
497
+ return {"error": f"Draft '{draft_id}' not found"}
498
+ if target.get("status") != "pending":
499
+ return {"error": f"Draft '{draft_id}' is already {target.get('status')}"}
500
+
501
+ target["status"] = "rejected"
502
+ target["rejected_at"] = datetime.now(timezone.utc).isoformat()
503
+ _rewrite_drafts(all_entries)
504
+ return {"draft_id": draft_id, "status": "rejected"}