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,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()
@@ -4,8 +4,19 @@
4
4
  * No PII. Silent fail. Never blocks install.
5
5
  */
6
6
 
7
- // Print setup hint
8
- console.log('\n Run: npx delimit-cli setup\n');
7
+ // Print setup hint with quick start
8
+ const v = require('../package.json').version;
9
+ console.log('');
10
+ console.log(' \x1b[1m\x1b[35mDelimit\x1b[0m v' + v + ' installed');
11
+ console.log('');
12
+ console.log(' Quick start:');
13
+ console.log(' \x1b[32mdelimit init\x1b[0m Auto-detect framework, set policy, first lint');
14
+ console.log(' \x1b[32mdelimit lint\x1b[0m Check for breaking API changes');
15
+ console.log(' \x1b[32mdelimit setup\x1b[0m Install MCP governance for AI assistants');
16
+ console.log('');
17
+ console.log(' Dashboard: \x1b[36mhttps://app.delimit.ai\x1b[0m');
18
+ console.log(' Docs: \x1b[36mhttps://delimit.ai/docs\x1b[0m');
19
+ console.log('');
9
20
 
10
21
  // Anonymous telemetry ping — no PII, just "someone installed"
11
22
  try {