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.
- package/CHANGELOG.md +5 -0
- package/bin/delimit-cli.js +139 -1
- package/bin/delimit-setup.js +10 -4
- package/gateway/ai/inbox_daemon.py +623 -0
- package/gateway/ai/ledger_manager.py +88 -19
- package/gateway/ai/notify.py +975 -0
- package/gateway/ai/server.py +3570 -426
- package/gateway/ai/social.py +504 -0
- package/gateway/ai/tool_metadata.py +201 -0
- package/lib/cross-model-hooks.js +173 -43
- package/package.json +1 -1
- package/scripts/crosspost_devto.py +304 -0
- package/scripts/postinstall.js +13 -2
|
@@ -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()
|
package/scripts/postinstall.js
CHANGED
|
@@ -4,8 +4,19 @@
|
|
|
4
4
|
* No PII. Silent fail. Never blocks install.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
// Print setup hint
|
|
8
|
-
|
|
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 {
|