delimit-cli 4.0.1 → 4.0.3

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,304 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Cross-post markdown articles to Dev.to.
4
+
5
+ Reads articles from a content directory, publishes or updates them
6
+ on Dev.to via the Forem API. Idempotent — tracks published article
7
+ IDs in a local manifest to support updates.
8
+
9
+ Usage:
10
+ # Publish all articles (dry run):
11
+ python scripts/crosspost_devto.py --dir /home/delimit/delimit-private/blog --dry-run
12
+
13
+ # Publish all articles:
14
+ DEV_TO_API_KEY=your_key python scripts/crosspost_devto.py --dir /home/delimit/delimit-private/blog
15
+
16
+ # Publish a single article:
17
+ DEV_TO_API_KEY=your_key python scripts/crosspost_devto.py --file /home/delimit/delimit-private/blog/01-catch-breaking-api-changes.md
18
+
19
+ # List published articles:
20
+ DEV_TO_API_KEY=your_key python scripts/crosspost_devto.py --list
21
+
22
+ Requires:
23
+ DEV_TO_API_KEY environment variable (get from https://dev.to/settings/extensions)
24
+ """
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ try:
34
+ import requests
35
+ except ImportError:
36
+ print("ERROR: 'requests' package required. Install with: pip install requests")
37
+ sys.exit(1)
38
+
39
+ API_BASE = "https://dev.to/api"
40
+ MANIFEST_FILE = ".devto_manifest.json"
41
+
42
+
43
+ def get_api_key():
44
+ key = os.environ.get("DEV_TO_API_KEY")
45
+ if not key:
46
+ print("ERROR: DEV_TO_API_KEY environment variable not set.")
47
+ print("Get your key from: https://dev.to/settings/extensions")
48
+ sys.exit(1)
49
+ return key
50
+
51
+
52
+ def load_manifest(content_dir):
53
+ """Load the manifest that maps filenames to Dev.to article IDs."""
54
+ manifest_path = Path(content_dir) / MANIFEST_FILE
55
+ if manifest_path.exists():
56
+ with open(manifest_path) as f:
57
+ return json.load(f)
58
+ return {}
59
+
60
+
61
+ def save_manifest(content_dir, manifest):
62
+ """Save the manifest after publishing."""
63
+ manifest_path = Path(content_dir) / MANIFEST_FILE
64
+ with open(manifest_path, "w") as f:
65
+ json.dump(manifest, f, indent=2)
66
+ print(f" Manifest saved: {manifest_path}")
67
+
68
+
69
+ def parse_frontmatter(filepath):
70
+ """Parse YAML-ish frontmatter from a markdown file.
71
+
72
+ Returns (frontmatter_dict, body_markdown).
73
+ """
74
+ text = Path(filepath).read_text(encoding="utf-8")
75
+
76
+ # Match frontmatter block
77
+ match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", text, re.DOTALL)
78
+ if not match:
79
+ print(f" WARNING: No frontmatter found in {filepath}")
80
+ return {}, text
81
+
82
+ fm_text = match.group(1)
83
+ body = match.group(2).strip()
84
+
85
+ # Simple key: value parser (handles quoted and unquoted values)
86
+ fm = {}
87
+ for line in fm_text.strip().split("\n"):
88
+ line = line.strip()
89
+ if not line or ":" not in line:
90
+ continue
91
+ key, _, value = line.partition(":")
92
+ key = key.strip()
93
+ value = value.strip().strip('"').strip("'")
94
+ # Parse boolean
95
+ if value.lower() == "true":
96
+ value = True
97
+ elif value.lower() == "false":
98
+ value = False
99
+ fm[key] = value
100
+
101
+ return fm, body
102
+
103
+
104
+ def build_article_payload(filepath, publish=False):
105
+ """Build the Dev.to article creation/update payload."""
106
+ fm, body = parse_frontmatter(filepath)
107
+
108
+ if not fm.get("title"):
109
+ print(f" ERROR: Article {filepath} has no title in frontmatter.")
110
+ return None
111
+
112
+ # Parse tags from comma-separated string
113
+ tags = []
114
+ if fm.get("tags"):
115
+ if isinstance(fm["tags"], str):
116
+ tags = [t.strip() for t in fm["tags"].split(",")]
117
+ else:
118
+ tags = fm["tags"]
119
+ # Dev.to allows max 4 tags
120
+ tags = tags[:4]
121
+
122
+ payload = {
123
+ "article": {
124
+ "title": fm["title"],
125
+ "body_markdown": body,
126
+ "published": publish,
127
+ "tags": tags,
128
+ }
129
+ }
130
+
131
+ if fm.get("description"):
132
+ payload["article"]["description"] = fm["description"]
133
+ if fm.get("canonical_url"):
134
+ payload["article"]["canonical_url"] = fm["canonical_url"]
135
+ if fm.get("cover_image"):
136
+ payload["article"]["cover_image"] = fm["cover_image"]
137
+ if fm.get("series"):
138
+ payload["article"]["series"] = fm["series"]
139
+
140
+ return payload
141
+
142
+
143
+ def create_article(api_key, payload):
144
+ """Create a new article on Dev.to."""
145
+ resp = requests.post(
146
+ f"{API_BASE}/articles",
147
+ headers={
148
+ "api-key": api_key,
149
+ "Content-Type": "application/json",
150
+ },
151
+ json=payload,
152
+ timeout=30,
153
+ )
154
+ resp.raise_for_status()
155
+ return resp.json()
156
+
157
+
158
+ def update_article(api_key, article_id, payload):
159
+ """Update an existing article on Dev.to."""
160
+ resp = requests.put(
161
+ f"{API_BASE}/articles/{article_id}",
162
+ headers={
163
+ "api-key": api_key,
164
+ "Content-Type": "application/json",
165
+ },
166
+ json=payload,
167
+ timeout=30,
168
+ )
169
+ resp.raise_for_status()
170
+ return resp.json()
171
+
172
+
173
+ def list_articles(api_key):
174
+ """List the authenticated user's articles."""
175
+ resp = requests.get(
176
+ f"{API_BASE}/articles/me/all",
177
+ headers={"api-key": api_key},
178
+ timeout=30,
179
+ )
180
+ resp.raise_for_status()
181
+ return resp.json()
182
+
183
+
184
+ def publish_file(api_key, filepath, manifest, content_dir, dry_run=False, publish=False):
185
+ """Publish or update a single article."""
186
+ filename = Path(filepath).name
187
+ print(f"\nProcessing: {filename}")
188
+
189
+ payload = build_article_payload(filepath, publish=publish)
190
+ if not payload:
191
+ return False
192
+
193
+ title = payload["article"]["title"]
194
+ existing_id = manifest.get(filename, {}).get("id")
195
+
196
+ if dry_run:
197
+ action = "UPDATE" if existing_id else "CREATE"
198
+ status = "published" if publish else "draft"
199
+ print(f" [DRY RUN] Would {action} ({status}): {title}")
200
+ if existing_id:
201
+ print(f" [DRY RUN] Existing article ID: {existing_id}")
202
+ print(f" [DRY RUN] Tags: {payload['article'].get('tags', [])}")
203
+ return True
204
+
205
+ try:
206
+ if existing_id:
207
+ print(f" Updating article {existing_id}: {title}")
208
+ result = update_article(api_key, existing_id, payload)
209
+ else:
210
+ print(f" Creating article: {title}")
211
+ result = create_article(api_key, payload)
212
+
213
+ article_id = result["id"]
214
+ article_url = result.get("url", f"https://dev.to/delimit_ai/{result.get('slug', '')}")
215
+
216
+ manifest[filename] = {
217
+ "id": article_id,
218
+ "url": article_url,
219
+ "title": title,
220
+ }
221
+ save_manifest(content_dir, manifest)
222
+
223
+ status = "PUBLISHED" if publish else "DRAFT"
224
+ print(f" {status}: {article_url}")
225
+ return True
226
+
227
+ except requests.exceptions.HTTPError as e:
228
+ print(f" ERROR: {e}")
229
+ if e.response is not None:
230
+ print(f" Response: {e.response.text[:500]}")
231
+ return False
232
+
233
+
234
+ def main():
235
+ parser = argparse.ArgumentParser(description="Cross-post articles to Dev.to")
236
+ parser.add_argument("--dir", help="Directory containing markdown articles")
237
+ parser.add_argument("--file", help="Single markdown file to publish")
238
+ parser.add_argument("--list", action="store_true", help="List published articles")
239
+ parser.add_argument("--dry-run", action="store_true", help="Preview without publishing")
240
+ parser.add_argument("--publish", action="store_true",
241
+ help="Publish articles (default: save as draft)")
242
+ args = parser.parse_args()
243
+
244
+ if args.list:
245
+ api_key = get_api_key()
246
+ articles = list_articles(api_key)
247
+ if not articles:
248
+ print("No articles found.")
249
+ return
250
+ print(f"Found {len(articles)} articles:\n")
251
+ for a in articles:
252
+ status = "PUBLISHED" if a.get("published") else "DRAFT"
253
+ print(f" [{status}] {a['title']}")
254
+ print(f" URL: {a.get('url', 'N/A')}")
255
+ print(f" ID: {a['id']}")
256
+ print()
257
+ return
258
+
259
+ if not args.dir and not args.file:
260
+ parser.print_help()
261
+ print("\nError: specify --dir or --file")
262
+ sys.exit(1)
263
+
264
+ api_key = None
265
+ if not args.dry_run:
266
+ api_key = get_api_key()
267
+
268
+ content_dir = args.dir or str(Path(args.file).parent)
269
+ manifest = load_manifest(content_dir)
270
+
271
+ files = []
272
+ if args.file:
273
+ files = [args.file]
274
+ else:
275
+ files = sorted(
276
+ str(p) for p in Path(args.dir).glob("*.md")
277
+ if not p.name.startswith(".")
278
+ and p.name not in ("README.md", "DEVTO_SETUP.md")
279
+ and not p.name.isupper() # skip ALL-CAPS docs like DEVTO_SETUP.md
280
+ )
281
+
282
+ if not files:
283
+ print(f"No markdown files found in {args.dir}")
284
+ sys.exit(1)
285
+
286
+ print(f"Found {len(files)} article(s) to process")
287
+ if args.dry_run:
288
+ print("MODE: dry run (no API calls)")
289
+ elif args.publish:
290
+ print("MODE: publish (articles will be live)")
291
+ else:
292
+ print("MODE: draft (articles saved as drafts on Dev.to)")
293
+
294
+ success = 0
295
+ for filepath in files:
296
+ if publish_file(api_key, filepath, manifest, content_dir,
297
+ dry_run=args.dry_run, publish=args.publish):
298
+ success += 1
299
+
300
+ print(f"\nDone: {success}/{len(files)} articles processed successfully.")
301
+
302
+
303
+ if __name__ == "__main__":
304
+ main()
@@ -0,0 +1,66 @@
1
+ #!/bin/bash
2
+ # Pre-publish security check — blocks npm publish if secrets are found
3
+ # Run: bash scripts/security-check.sh
4
+
5
+ set -euo pipefail
6
+
7
+ echo "🔍 Delimit pre-publish security scan..."
8
+
9
+ FAIL=0
10
+
11
+ # Pack to temp and scan the actual tarball contents
12
+ TMPDIR=$(mktemp -d)
13
+ npm pack --pack-destination "$TMPDIR" --quiet 2>/dev/null
14
+ TARBALL=$(ls "$TMPDIR"/*.tgz)
15
+ tar -xzf "$TARBALL" -C "$TMPDIR"
16
+
17
+ # 1. Credential patterns
18
+ echo -n " Credentials... "
19
+ if grep -rEi '(password|passwd|secret|api_key|apikey)\s*[:=]\s*["\x27][^"\x27]{4,}' "$TMPDIR/package/" --include="*.py" --include="*.js" --include="*.json" 2>/dev/null | grep -v 'environ\|getenv\|process\.env\|os\.environ\|<configured\|example\|placeholder\|REDACTED\|\${credentials\|credentials\.\|security-scan-ignore'; then
20
+ echo "❌ FOUND CREDENTIALS"
21
+ FAIL=1
22
+ else
23
+ echo "✅ clean"
24
+ fi
25
+
26
+ # 2. Blocklist terms
27
+ echo -n " Blocklist... "
28
+ BLOCKLIST="jamsonsholdings|Bladabah|Domainvested26|Delimit26|home/jamsons|infracore|crypttrx|\.wr_env"
29
+ if grep -rEi "$BLOCKLIST" "$TMPDIR/package/" --include="*.py" --include="*.js" --include="*.json" 2>/dev/null; then
30
+ echo "❌ BLOCKED TERMS FOUND"
31
+ FAIL=1
32
+ else
33
+ echo "✅ clean"
34
+ fi
35
+
36
+ # 3. PII (email addresses that aren't examples)
37
+ echo -n " PII... "
38
+ if grep -rEi '[a-z0-9._%+-]+@(gmail|yahoo|hotmail|outlook|proton|jamsons|wire\.report|domainvested)' "$TMPDIR/package/" --include="*.py" --include="*.js" --include="*.json" 2>/dev/null | grep -v "example\|placeholder\|<configured\|noreply\|e\.g\.\|docstring\|Args:\|Credential resolution"; then
39
+ echo "❌ PII FOUND"
40
+ FAIL=1
41
+ else
42
+ echo "✅ clean"
43
+ fi
44
+
45
+ # 4. Proprietary files that shouldn't ship
46
+ echo -n " Proprietary files... "
47
+ PROPRIETARY="social_target\.py|social\.py|founding_users\.py|inbox_daemon\.py|deliberation\.py"
48
+ if find "$TMPDIR/package/" -name "*.py" | grep -Ei "$PROPRIETARY" 2>/dev/null; then
49
+ echo "❌ PROPRIETARY FILES IN PACKAGE"
50
+ FAIL=1
51
+ else
52
+ echo "✅ clean"
53
+ fi
54
+
55
+ # Cleanup
56
+ rm -rf "$TMPDIR"
57
+
58
+ if [ $FAIL -ne 0 ]; then
59
+ echo ""
60
+ echo "❌ SECURITY CHECK FAILED — do not publish"
61
+ exit 1
62
+ fi
63
+
64
+ echo ""
65
+ echo "✅ All security checks passed"
66
+ exit 0
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Weekly Activity Tweet for @delimit_ai.
4
+
5
+ Gathers GitHub activity stats across delimit-ai repos and npm download
6
+ counts, then posts a summary tweet via the Twitter API.
7
+
8
+ Reads Twitter credentials from environment variables (for GitHub Actions).
9
+ """
10
+
11
+ import os
12
+ import sys
13
+ import json
14
+ from datetime import datetime, timedelta, timezone
15
+
16
+ import requests
17
+ import tweepy
18
+
19
+ ORG = "delimit-ai"
20
+ NPM_PACKAGE = "delimit-cli"
21
+ GITHUB_API = "https://api.github.com"
22
+ NPM_API = "https://api.npmjs.org"
23
+
24
+
25
+ def get_org_repos():
26
+ """Fetch all public repos for the org."""
27
+ repos = []
28
+ page = 1
29
+ while True:
30
+ resp = requests.get(
31
+ f"{GITHUB_API}/orgs/{ORG}/repos",
32
+ params={"type": "public", "per_page": 100, "page": page},
33
+ )
34
+ if resp.status_code != 200:
35
+ break
36
+ batch = resp.json()
37
+ if not batch:
38
+ break
39
+ repos.extend(batch)
40
+ page += 1
41
+ return repos
42
+
43
+
44
+ def get_total_stars(repos):
45
+ """Sum stargazers across all repos."""
46
+ return sum(r.get("stargazers_count", 0) for r in repos)
47
+
48
+
49
+ def get_commits_last_week(repos):
50
+ """Count commits in the last 7 days across all repos."""
51
+ since = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
52
+ total = 0
53
+ for repo in repos:
54
+ name = repo["full_name"]
55
+ page = 1
56
+ while True:
57
+ resp = requests.get(
58
+ f"{GITHUB_API}/repos/{name}/commits",
59
+ params={"since": since, "per_page": 100, "page": page},
60
+ )
61
+ if resp.status_code != 200:
62
+ break
63
+ batch = resp.json()
64
+ if not batch:
65
+ break
66
+ total += len(batch)
67
+ if len(batch) < 100:
68
+ break
69
+ page += 1
70
+ return total
71
+
72
+
73
+ def get_prs_merged_last_week(repos):
74
+ """Count PRs merged in the last 7 days."""
75
+ since = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
76
+ total = 0
77
+ for repo in repos:
78
+ name = repo["full_name"]
79
+ resp = requests.get(
80
+ f"{GITHUB_API}/repos/{name}/pulls",
81
+ params={"state": "closed", "sort": "updated", "direction": "desc", "per_page": 100},
82
+ )
83
+ if resp.status_code != 200:
84
+ continue
85
+ for pr in resp.json():
86
+ if pr.get("merged_at") and pr["merged_at"] >= since:
87
+ total += 1
88
+ return total
89
+
90
+
91
+ def get_issues_stats(repos):
92
+ """Count issues opened and closed in the last 7 days."""
93
+ since = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
94
+ opened = 0
95
+ closed = 0
96
+ for repo in repos:
97
+ name = repo["full_name"]
98
+ # Opened
99
+ resp = requests.get(
100
+ f"{GITHUB_API}/repos/{name}/issues",
101
+ params={"state": "all", "since": since, "per_page": 100},
102
+ )
103
+ if resp.status_code == 200:
104
+ for issue in resp.json():
105
+ if issue.get("pull_request"):
106
+ continue
107
+ if issue.get("created_at", "") >= since:
108
+ opened += 1
109
+ if issue.get("closed_at") and issue["closed_at"] >= since:
110
+ closed += 1
111
+ return opened, closed
112
+
113
+
114
+ def get_npm_downloads():
115
+ """Get npm download count for the last week."""
116
+ resp = requests.get(f"{NPM_API}/downloads/point/last-week/{NPM_PACKAGE}")
117
+ if resp.status_code != 200:
118
+ return 0
119
+ return resp.json().get("downloads", 0)
120
+
121
+
122
+ def format_tweet(npm_downloads, total_stars, prs_merged, commits):
123
+ """Format the weekly summary tweet."""
124
+ lines = ["This week at Delimit:", ""]
125
+ if npm_downloads > 0:
126
+ lines.append(f"\U0001f4e6 {npm_downloads:,} npm downloads")
127
+ if total_stars > 0:
128
+ lines.append(f"\u2b50 {total_stars:,} stars")
129
+ if prs_merged > 0:
130
+ lines.append(f"\U0001f500 {prs_merged:,} PRs merged")
131
+ if commits > 0:
132
+ lines.append(f"\U0001f6e0\ufe0f {commits:,} commits")
133
+ lines.append("")
134
+ lines.append("Keep Building.")
135
+ lines.append("")
136
+ lines.append("delimit.ai")
137
+ return "\n".join(lines)
138
+
139
+
140
+ def post_tweet(text):
141
+ """Post tweet using tweepy with OAuth 1.0a credentials from env vars."""
142
+ consumer_key = os.environ.get("TWITTER_CONSUMER_KEY")
143
+ consumer_secret = os.environ.get("TWITTER_CONSUMER_SECRET")
144
+ access_token = os.environ.get("TWITTER_ACCESS_TOKEN")
145
+ access_token_secret = os.environ.get("TWITTER_ACCESS_TOKEN_SECRET")
146
+
147
+ if not all([consumer_key, consumer_secret, access_token, access_token_secret]):
148
+ print("ERROR: Missing Twitter credentials in environment variables.")
149
+ sys.exit(1)
150
+
151
+ client = tweepy.Client(
152
+ consumer_key=consumer_key,
153
+ consumer_secret=consumer_secret,
154
+ access_token=access_token,
155
+ access_token_secret=access_token_secret,
156
+ )
157
+ response = client.create_tweet(text=text)
158
+ print(f"Tweet posted: https://x.com/delimit_ai/status/{response.data['id']}")
159
+
160
+
161
+ def main():
162
+ print("Gathering weekly stats for delimit-ai...")
163
+
164
+ repos = get_org_repos()
165
+ print(f" Found {len(repos)} public repos")
166
+
167
+ npm_downloads = get_npm_downloads()
168
+ print(f" npm downloads (last week): {npm_downloads}")
169
+
170
+ total_stars = get_total_stars(repos)
171
+ print(f" Total stars: {total_stars}")
172
+
173
+ prs_merged = get_prs_merged_last_week(repos)
174
+ print(f" PRs merged (last week): {prs_merged}")
175
+
176
+ commits = get_commits_last_week(repos)
177
+ print(f" Commits (last week): {commits}")
178
+
179
+ # Don't tweet if there's nothing to report
180
+ if npm_downloads == 0 and total_stars == 0 and prs_merged == 0 and commits == 0:
181
+ print("No activity this week. Skipping tweet.")
182
+ return
183
+
184
+ tweet_text = format_tweet(npm_downloads, total_stars, prs_merged, commits)
185
+ print(f"\nTweet ({len(tweet_text)} chars):\n{tweet_text}\n")
186
+
187
+ post_tweet(tweet_text)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()