delimit-cli 4.0.0 → 4.0.1
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 +1 -1
- package/gateway/ai/notify.py +4 -4
- package/gateway/ai/server.py +44 -326
- package/package.json +4 -1
- package/scripts/crosspost_devto.py +0 -304
- package/scripts/security-check.sh +0 -110
- package/scripts/weekly-tweet.py +0 -191
package/CHANGELOG.md
CHANGED
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
- GitHub Action smoke test workflow
|
|
118
118
|
|
|
119
119
|
### Fixed
|
|
120
|
-
- Gemini deliberation HTTP 400 (ADC credentials +
|
|
120
|
+
- Gemini deliberation HTTP 400 (ADC credentials + GCP project)
|
|
121
121
|
- Deliberation timeout: parallelized round 1 (46% faster)
|
|
122
122
|
- Sensor dedup: titles include repo/issue to prevent duplicates
|
|
123
123
|
- Test-mode guard prevents ledger pollution from tests
|
package/gateway/ai/notify.py
CHANGED
|
@@ -192,7 +192,7 @@ def _load_smtp_account(from_account: str) -> Optional[Dict[str, str]]:
|
|
|
192
192
|
"""Load SMTP credentials from smtp-all.json for a given account.
|
|
193
193
|
|
|
194
194
|
Args:
|
|
195
|
-
from_account: Email address key in smtp-all.json (e.g. '
|
|
195
|
+
from_account: Email address key in smtp-all.json (e.g. 'notifications@example.com').
|
|
196
196
|
|
|
197
197
|
Returns:
|
|
198
198
|
Dict with host, port, user, pass keys, or None if not found.
|
|
@@ -228,7 +228,7 @@ def send_email(
|
|
|
228
228
|
body: Email body text (preferred). Falls back to 'message' for
|
|
229
229
|
backward compatibility.
|
|
230
230
|
from_account: Sender account key in ~/.delimit/secrets/smtp-all.json
|
|
231
|
-
(e.g. '
|
|
231
|
+
(e.g. 'notifications@example.com', 'admin@example.com'). If provided, SMTP
|
|
232
232
|
credentials are loaded from that file instead of env vars.
|
|
233
233
|
message: Email body text (legacy parameter, use 'body' instead).
|
|
234
234
|
event_type: Event category for filtering/logging.
|
|
@@ -744,7 +744,7 @@ def _forward_email(original_msg: email.message.Message, smtp_pass: str) -> bool:
|
|
|
744
744
|
body = "\n".join(body_parts) if body_parts else "(no text content)"
|
|
745
745
|
|
|
746
746
|
fwd_text = (
|
|
747
|
-
f"--- Forwarded from
|
|
747
|
+
f"--- Forwarded from notifications@example.com ---\n"
|
|
748
748
|
f"From: {from_header}\n"
|
|
749
749
|
f"Subject: {subject}\n"
|
|
750
750
|
f"Date: {original_msg.get('Date', 'unknown')}\n"
|
|
@@ -776,7 +776,7 @@ def poll_inbox(
|
|
|
776
776
|
"""Poll the IMAP inbox, classify emails, and optionally forward owner-action items.
|
|
777
777
|
|
|
778
778
|
Args:
|
|
779
|
-
smtp_pass: SMTP/IMAP password for
|
|
779
|
+
smtp_pass: SMTP/IMAP password for notifications@example.com.
|
|
780
780
|
limit: Max number of recent messages to check.
|
|
781
781
|
process: If True, forward owner-action emails and mark as read.
|
|
782
782
|
If False, just report classification (dry run).
|
package/gateway/ai/server.py
CHANGED
|
@@ -33,14 +33,7 @@ All tools follow the Adapter Boundary Contract v1.0:
|
|
|
33
33
|
# attached to decisions? Would this still read well a year from now?
|
|
34
34
|
# ────────────────────────────────────────────────────────────────────────
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
"revolutionary", "game-changing", "world-class", "cutting-edge",
|
|
38
|
-
"best-in-class", "seamless", "unlock", "supercharge", "next-generation",
|
|
39
|
-
"magical", "delightful", "effortless", "frictionless", "transformative",
|
|
40
|
-
"paradigm shift", "visionary", "category-defining", "industry-leading",
|
|
41
|
-
"innovative", "reimagine", "future of", "changing the game",
|
|
42
|
-
"empowering teams", "built for everyone",
|
|
43
|
-
}
|
|
36
|
+
# Output quality rules loaded from user config at runtime.
|
|
44
37
|
|
|
45
38
|
import json
|
|
46
39
|
import logging
|
|
@@ -59,13 +52,13 @@ from fastmcp import FastMCP
|
|
|
59
52
|
logger = logging.getLogger("delimit.ai")
|
|
60
53
|
|
|
61
54
|
# ═══════════════════════════════════════════════════════════════════════
|
|
62
|
-
#
|
|
55
|
+
# TASK: Agent Identity — session tracking for every tool call
|
|
63
56
|
# ═══════════════════════════════════════════════════════════════════════
|
|
64
57
|
|
|
65
58
|
_current_session_id = os.environ.get("DELIMIT_SESSION_ID", "")
|
|
66
59
|
|
|
67
60
|
# ═══════════════════════════════════════════════════════════════════════
|
|
68
|
-
#
|
|
61
|
+
# TASK: Distributed Tracing — trace ID + span counter for every call
|
|
69
62
|
# ═══════════════════════════════════════════════════════════════════════
|
|
70
63
|
|
|
71
64
|
_trace_id = os.environ.get("DELIMIT_TRACE_ID", str(uuid.uuid4())[:8])
|
|
@@ -250,7 +243,7 @@ def _coerce_dict_arg(
|
|
|
250
243
|
|
|
251
244
|
|
|
252
245
|
# ═══════════════════════════════════════════════════════════════════════
|
|
253
|
-
#
|
|
246
|
+
# TASK: Risk Classification for Approval Gates
|
|
254
247
|
# ═══════════════════════════════════════════════════════════════════════
|
|
255
248
|
|
|
256
249
|
HIGH_RISK_TOOLS = {
|
|
@@ -274,7 +267,7 @@ def _classify_risk(tool_name: str) -> str:
|
|
|
274
267
|
|
|
275
268
|
|
|
276
269
|
# ═══════════════════════════════════════════════════════════════════════
|
|
277
|
-
#
|
|
270
|
+
# TASK: Policy Kernel — Inline Enforcement
|
|
278
271
|
# Checks policy BEFORE/AFTER tool execution to block high-risk actions.
|
|
279
272
|
# ═══════════════════════════════════════════════════════════════════════
|
|
280
273
|
|
|
@@ -326,7 +319,7 @@ def _check_policy_gate(tool_name: str, kwargs: dict) -> Optional[Dict]:
|
|
|
326
319
|
"action": "Switch to guarded mode or request approval",
|
|
327
320
|
}
|
|
328
321
|
|
|
329
|
-
#
|
|
322
|
+
# TASK: Deploy gating — block deploys when unresolved critical findings exist
|
|
330
323
|
DEPLOY_TOOLS = {"deploy_publish", "deploy_npm", "deploy_site", "deploy_build"}
|
|
331
324
|
clean = tool_name.replace("delimit_", "")
|
|
332
325
|
if clean in DEPLOY_TOOLS and mode != "advisory":
|
|
@@ -454,7 +447,7 @@ mcp.description = (
|
|
|
454
447
|
|
|
455
448
|
VERSION = "3.2.1"
|
|
456
449
|
|
|
457
|
-
#
|
|
450
|
+
# TASK + Consensus 118/119/120: Tool visibility tiers.
|
|
458
451
|
# Tier cascade: SHOW_EXPERIMENTAL > SHOW_INTERNAL > SHOW_OPS > public (always visible).
|
|
459
452
|
# Set DELIMIT_SHOW_INTERNAL=1 to see all tiers (founder workflow).
|
|
460
453
|
SHOW_EXPERIMENTAL = os.environ.get("DELIMIT_SHOW_EXPERIMENTAL", "") == "1"
|
|
@@ -839,7 +832,7 @@ NEXT_STEPS_REGISTRY: Dict[str, List[Dict[str, Any]]] = {
|
|
|
839
832
|
],
|
|
840
833
|
# --- Sensing ---
|
|
841
834
|
"sensor_github_issue": [],
|
|
842
|
-
# --- Context Filesystem (
|
|
835
|
+
# --- Context Filesystem (TASK) ---
|
|
843
836
|
"context_init": [
|
|
844
837
|
{"tool": "delimit_context_write", "reason": "Write an artifact to the new context", "suggested_args": {}, "is_premium": False},
|
|
845
838
|
],
|
|
@@ -965,10 +958,10 @@ def _emit_event(tool_name: str, result: Dict[str, Any]) -> None:
|
|
|
965
958
|
except Exception:
|
|
966
959
|
pass # Never let cloud sync break tool execution
|
|
967
960
|
|
|
968
|
-
#
|
|
961
|
+
# TASK: Webhook notifications for governance events
|
|
969
962
|
_fire_webhook(event)
|
|
970
963
|
|
|
971
|
-
#
|
|
964
|
+
# TASK: Write trace span for session replay
|
|
972
965
|
try:
|
|
973
966
|
from ai.tracing import start_span, end_span
|
|
974
967
|
span = start_span(_trace_id, tool_name, args={"tool": tool_name})
|
|
@@ -980,7 +973,7 @@ def _emit_event(tool_name: str, result: Dict[str, Any]) -> None:
|
|
|
980
973
|
except Exception:
|
|
981
974
|
pass # Tracing is best-effort
|
|
982
975
|
|
|
983
|
-
#
|
|
976
|
+
# TASK: Write to agent_actions log for session drill-down
|
|
984
977
|
if session_info["session_id"]:
|
|
985
978
|
actions_dir = Path.home() / ".delimit" / "agent_actions"
|
|
986
979
|
actions_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -1184,7 +1177,7 @@ def _with_next_steps(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1184
1177
|
except Exception as e:
|
|
1185
1178
|
logger.warning("Inbox daemon auto-start failed: %s", e)
|
|
1186
1179
|
|
|
1187
|
-
#
|
|
1180
|
+
# TASK: Auto-register tool schemas with toolcard cache on first call
|
|
1188
1181
|
global _toolcard_cache_autoregistered
|
|
1189
1182
|
if not _toolcard_cache_autoregistered:
|
|
1190
1183
|
_toolcard_cache_autoregistered = True
|
|
@@ -1202,7 +1195,7 @@ def _with_next_steps(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1202
1195
|
except Exception as e:
|
|
1203
1196
|
logger.warning("Toolcard cache auto-register failed: %s", e)
|
|
1204
1197
|
|
|
1205
|
-
#
|
|
1198
|
+
# TASK: Track every tool call for session analytics
|
|
1206
1199
|
try:
|
|
1207
1200
|
from ai.toolcard_cache import get_cache as _get_tc
|
|
1208
1201
|
_get_tc().record_call(tool_name)
|
|
@@ -1215,282 +1208,7 @@ def _with_next_steps(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1215
1208
|
result.get("explanation", ""), result.get("changelog", ""),
|
|
1216
1209
|
result.get("content", "")]
|
|
1217
1210
|
_all_text = " ".join(str(f) for f in _text_fields if f).lower()
|
|
1218
|
-
|
|
1219
|
-
if _found_hype:
|
|
1220
|
-
result.setdefault("voice_warnings", []).append(
|
|
1221
|
-
f"VOICE DOCTRINE: Hype words detected: {', '.join(_found_hype)}. "
|
|
1222
|
-
f"Rewrite with concrete mechanisms, not vague benefits."
|
|
1223
|
-
)
|
|
1224
|
-
|
|
1225
|
-
# Emit event for real-time dashboard
|
|
1226
|
-
_emit_event(tool_name, result)
|
|
1227
|
-
|
|
1228
|
-
# STR-052: Policy kernel inline enforcement
|
|
1229
|
-
policy_gate = _check_policy_gate(tool_name, result if isinstance(result, dict) else {})
|
|
1230
|
-
if policy_gate:
|
|
1231
|
-
policy_gate["original_result"] = result
|
|
1232
|
-
policy_gate["governance"] = {"action": "policy_blocked", "reason": policy_gate["reason"]}
|
|
1233
|
-
return policy_gate
|
|
1234
|
-
|
|
1235
|
-
# LED-195: Prompt injection detection on tool inputs
|
|
1236
|
-
if isinstance(result, dict):
|
|
1237
|
-
injection = _detect_prompt_injection(result, tool_name)
|
|
1238
|
-
if injection:
|
|
1239
|
-
result["_security_warning"] = injection
|
|
1240
|
-
|
|
1241
|
-
# Pro license gate — blocks execution for premium tools
|
|
1242
|
-
full_name = f"delimit_{tool_name}" if not tool_name.startswith("delimit_") else tool_name
|
|
1243
|
-
gate = _check_pro(full_name)
|
|
1244
|
-
if gate:
|
|
1245
|
-
return gate
|
|
1246
|
-
|
|
1247
|
-
# Route through governance loop
|
|
1248
|
-
try:
|
|
1249
|
-
from ai.governance import govern
|
|
1250
|
-
return govern(tool_name, result)
|
|
1251
|
-
except Exception:
|
|
1252
|
-
# Fallback: just add next_steps from registry
|
|
1253
|
-
steps = NEXT_STEPS_REGISTRY.get(tool_name, [])
|
|
1254
|
-
result["next_steps"] = steps
|
|
1255
|
-
return result
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
# ═══════════════════════════════════════════════════════════════════════
|
|
1259
|
-
# TIER 1: CORE — API Lint Engine
|
|
1260
|
-
# ═══════════════════════════════════════════════════════════════════════
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
@mcp.tool()
|
|
1264
|
-
def delimit_lint(old_spec: str, new_spec: str, policy_file: Optional[str] = None) -> Dict[str, Any]:
|
|
1265
|
-
"""Lint two OpenAPI specs for breaking changes and policy violations.
|
|
1266
|
-
Primary CI integration point. Combines diff + policy into pass/fail.
|
|
1267
|
-
Auto-chains: semver classification, governance evaluation on breaking changes.
|
|
1268
|
-
|
|
1269
|
-
Args:
|
|
1270
|
-
old_spec: Path to the old (baseline) OpenAPI spec file.
|
|
1271
|
-
new_spec: Path to the new (proposed) OpenAPI spec file.
|
|
1272
|
-
policy_file: Optional path to a .delimit/policies.yml file.
|
|
1273
|
-
"""
|
|
1274
|
-
from backends.gateway_core import run_lint, run_semver
|
|
1275
|
-
|
|
1276
|
-
# Step 1: Core lint
|
|
1277
|
-
lint_result = _safe_call(run_lint, old_spec=old_spec, new_spec=new_spec, policy_file=policy_file)
|
|
1278
|
-
chain: Dict[str, Any] = {"id": "lint_chain", "steps": []}
|
|
1279
|
-
|
|
1280
|
-
if lint_result.get("error"):
|
|
1281
|
-
lint_result["chain"] = chain
|
|
1282
|
-
return _with_next_steps("lint", lint_result)
|
|
1283
|
-
|
|
1284
|
-
# Step 2: Auto-classify semver bump (non-blocking on failure)
|
|
1285
|
-
semver_result = _chain_call("lint", "semver", run_semver,
|
|
1286
|
-
required=False, old_spec=old_spec, new_spec=new_spec)
|
|
1287
|
-
chain["steps"].append({"step": "semver", "ok": not _chain_is_error(semver_result)})
|
|
1288
|
-
lint_result["semver"] = semver_result
|
|
1289
|
-
|
|
1290
|
-
if _chain_is_error(semver_result):
|
|
1291
|
-
chain["status"] = "semver_failed_nonfatal"
|
|
1292
|
-
lint_result["chain"] = chain
|
|
1293
|
-
return _with_next_steps("lint", lint_result)
|
|
1294
|
-
|
|
1295
|
-
bump = str(semver_result.get("bump", "")).upper()
|
|
1296
|
-
|
|
1297
|
-
# Step 2b: Impact-based notification routing (LED-233, non-blocking)
|
|
1298
|
-
try:
|
|
1299
|
-
from ai.notify import route_by_impact
|
|
1300
|
-
all_changes = lint_result.get("all_changes", lint_result.get("violations", []))
|
|
1301
|
-
if all_changes:
|
|
1302
|
-
routing_result = route_by_impact(all_changes, dry_run=False)
|
|
1303
|
-
chain["steps"].append({"step": "impact_routing", "ok": True})
|
|
1304
|
-
lint_result["impact_routing"] = routing_result
|
|
1305
|
-
except Exception as e:
|
|
1306
|
-
logger.debug("Impact routing non-fatal error: %s", e)
|
|
1307
|
-
chain["steps"].append({"step": "impact_routing", "ok": False, "error": str(e)})
|
|
1308
|
-
|
|
1309
|
-
if bump != "MAJOR":
|
|
1310
|
-
chain["status"] = f"complete_{bump.lower() or 'none'}"
|
|
1311
|
-
lint_result["chain"] = chain
|
|
1312
|
-
return _with_next_steps("lint", lint_result)
|
|
1313
|
-
|
|
1314
|
-
# Step 3: MAJOR bump detected -- evaluate governance
|
|
1315
|
-
# Note: _delimit_gov_impl has its own Pro gate. Free-tier gets lint+semver only.
|
|
1316
|
-
gov_result = _delimit_gov_impl(
|
|
1317
|
-
action="evaluate",
|
|
1318
|
-
eval_action="api_breaking_change",
|
|
1319
|
-
context={
|
|
1320
|
-
"tool": "delimit_lint",
|
|
1321
|
-
"old_spec": old_spec,
|
|
1322
|
-
"new_spec": new_spec,
|
|
1323
|
-
"semver_bump": bump,
|
|
1324
|
-
"breaking_changes": lint_result.get("breaking", []),
|
|
1325
|
-
},
|
|
1326
|
-
repo=".",
|
|
1327
|
-
)
|
|
1328
|
-
chain["steps"].append({"step": "gov_evaluate", "ok": not _chain_is_error(gov_result)})
|
|
1329
|
-
lint_result["gov_evaluate"] = gov_result
|
|
1330
|
-
|
|
1331
|
-
# If Pro gate blocked governance, return gracefully with lint+semver
|
|
1332
|
-
if gov_result.get("status") == "premium_required":
|
|
1333
|
-
chain["status"] = "governance_skipped_free_tier"
|
|
1334
|
-
lint_result["chain"] = chain
|
|
1335
|
-
return _with_next_steps("lint", lint_result)
|
|
1336
|
-
|
|
1337
|
-
# Step 4: If governance blocked, record in ledger (best-effort)
|
|
1338
|
-
gov_blocked = (
|
|
1339
|
-
str(gov_result.get("status", "")).lower() == "blocked"
|
|
1340
|
-
or gov_result.get("governance", {}).get("action") == "policy_blocked"
|
|
1341
|
-
)
|
|
1342
|
-
|
|
1343
|
-
if gov_blocked:
|
|
1344
|
-
from ai.ledger_manager import add_item
|
|
1345
|
-
ledger_result = _chain_call(
|
|
1346
|
-
"lint", "ledger_add", add_item,
|
|
1347
|
-
required=False,
|
|
1348
|
-
title=f"Governance blocked: MAJOR API change in {new_spec}",
|
|
1349
|
-
ledger="ops",
|
|
1350
|
-
item_type="fix",
|
|
1351
|
-
priority="P0",
|
|
1352
|
-
description="MAJOR semver bump detected. Governance blocked the change.",
|
|
1353
|
-
source="chain:lint:gov_blocked",
|
|
1354
|
-
)
|
|
1355
|
-
chain["steps"].append({"step": "ledger_add", "ok": not _chain_is_error(ledger_result)})
|
|
1356
|
-
lint_result["governance_blocked"] = True
|
|
1357
|
-
else:
|
|
1358
|
-
lint_result["governance_blocked"] = False
|
|
1359
|
-
|
|
1360
|
-
chain["status"] = "major_change_evaluated"
|
|
1361
|
-
lint_result["chain"] = chain
|
|
1362
|
-
return _with_next_steps("lint", lint_result)
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
@mcp.tool()
|
|
1366
|
-
def delimit_diff(old_spec: str, new_spec: str) -> Dict[str, Any]:
|
|
1367
|
-
"""Diff two OpenAPI specs and list all changes. Pure diff, no policy.
|
|
1368
|
-
|
|
1369
|
-
Args:
|
|
1370
|
-
old_spec: Path to the old OpenAPI spec file.
|
|
1371
|
-
new_spec: Path to the new OpenAPI spec file.
|
|
1372
|
-
"""
|
|
1373
|
-
from backends.gateway_core import run_diff
|
|
1374
|
-
return _with_next_steps("diff", _safe_call(run_diff, old_spec=old_spec, new_spec=new_spec))
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
@mcp.tool()
|
|
1378
|
-
def delimit_policy(spec_files: List[str], policy_file: Optional[str] = None) -> Dict[str, Any]:
|
|
1379
|
-
"""Inspect or validate governance policy configuration.
|
|
1380
|
-
|
|
1381
|
-
Args:
|
|
1382
|
-
spec_files: List of spec file paths.
|
|
1383
|
-
policy_file: Optional custom policy file path.
|
|
1384
|
-
"""
|
|
1385
|
-
from backends.gateway_core import run_policy
|
|
1386
|
-
return _with_next_steps("policy", _safe_call(run_policy, spec_files=spec_files, policy_file=policy_file))
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
@mcp.tool()
|
|
1390
|
-
def delimit_ledger(ledger_path: str, api_name: Optional[str] = None, repository: Optional[str] = None, validate_chain: bool = False) -> Dict[str, Any]:
|
|
1391
|
-
"""Query the append-only contract ledger (hash-chained JSONL).
|
|
1392
|
-
|
|
1393
|
-
Args:
|
|
1394
|
-
ledger_path: Path to the ledger JSONL file (e.g. .delimit/ledger/operations.jsonl).
|
|
1395
|
-
api_name: Filter events by API name.
|
|
1396
|
-
repository: Filter events by repository.
|
|
1397
|
-
validate_chain: Validate hash chain integrity.
|
|
1398
|
-
"""
|
|
1399
|
-
from backends.gateway_core import query_ledger
|
|
1400
|
-
return _with_next_steps("ledger", _safe_call(query_ledger, ledger_path=ledger_path, api_name=api_name, repository=repository, validate_chain=validate_chain))
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
@mcp.tool()
|
|
1404
|
-
def delimit_impact(api_name: str, dependency_file: Optional[str] = None) -> Dict[str, Any]:
|
|
1405
|
-
"""Analyze downstream impact of an API change. Informational only.
|
|
1406
|
-
|
|
1407
|
-
Args:
|
|
1408
|
-
api_name: The API that changed.
|
|
1409
|
-
dependency_file: Optional path to dependency manifest.
|
|
1410
|
-
"""
|
|
1411
|
-
from backends.gateway_core import run_impact
|
|
1412
|
-
return _with_next_steps("impact", _safe_call(run_impact, api_name=api_name, dependency_file=dependency_file))
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
@mcp.tool()
|
|
1416
|
-
def delimit_semver(old_spec: str, new_spec: str, current_version: Optional[str] = None) -> Dict[str, Any]:
|
|
1417
|
-
"""Classify the semver bump for a spec change (MAJOR/MINOR/PATCH/NONE).
|
|
1418
|
-
|
|
1419
|
-
Deterministic classification based on diff engine output.
|
|
1420
|
-
Optionally computes the next version string.
|
|
1421
|
-
|
|
1422
|
-
Args:
|
|
1423
|
-
old_spec: Path to the old OpenAPI spec file.
|
|
1424
|
-
new_spec: Path to the new OpenAPI spec file.
|
|
1425
|
-
current_version: Optional current version (e.g. "1.2.3") to compute next version.
|
|
1426
|
-
"""
|
|
1427
|
-
from backends.gateway_core import run_semver
|
|
1428
|
-
return _with_next_steps("semver", _safe_call(run_semver, old_spec=old_spec, new_spec=new_spec, current_version=current_version))
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
@mcp.tool()
|
|
1432
|
-
def delimit_explain(
|
|
1433
|
-
old_spec: str,
|
|
1434
|
-
new_spec: str,
|
|
1435
|
-
template: str = "developer",
|
|
1436
|
-
old_version: Optional[str] = None,
|
|
1437
|
-
new_version: Optional[str] = None,
|
|
1438
|
-
api_name: Optional[str] = None,
|
|
1439
|
-
) -> Dict[str, Any]:
|
|
1440
|
-
"""Generate a human-readable explanation of API changes.
|
|
1441
|
-
|
|
1442
|
-
7 templates: developer, team_lead, product, migration, changelog, pr_comment, slack.
|
|
1443
|
-
|
|
1444
|
-
Args:
|
|
1445
|
-
old_spec: Path to the old OpenAPI spec file.
|
|
1446
|
-
new_spec: Path to the new OpenAPI spec file.
|
|
1447
|
-
template: Template name (default: developer).
|
|
1448
|
-
old_version: Previous version string.
|
|
1449
|
-
new_version: New version string.
|
|
1450
|
-
api_name: API/service name for context.
|
|
1451
|
-
"""
|
|
1452
|
-
from backends.gateway_core import run_explain
|
|
1453
|
-
return _with_next_steps("explain", _safe_call(run_explain, old_spec=old_spec, new_spec=new_spec, template=template, old_version=old_version, new_version=new_version, api_name=api_name))
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
@mcp.tool()
|
|
1457
|
-
def delimit_zero_spec(
|
|
1458
|
-
project_dir: str = ".",
|
|
1459
|
-
python_bin: Optional[str] = None,
|
|
1460
|
-
) -> Dict[str, Any]:
|
|
1461
|
-
"""Extract OpenAPI spec from framework source code (no spec file needed).
|
|
1462
|
-
|
|
1463
|
-
Detects the API framework (FastAPI, Express, NestJS) and extracts a
|
|
1464
|
-
complete OpenAPI specification directly from the source code.
|
|
1465
|
-
Currently supports FastAPI with full fidelity.
|
|
1466
|
-
|
|
1467
|
-
Args:
|
|
1468
|
-
project_dir: Path to the project root directory.
|
|
1469
|
-
python_bin: Optional Python binary path (auto-detected if omitted).
|
|
1470
|
-
"""
|
|
1471
|
-
from backends.gateway_core import run_zero_spec
|
|
1472
|
-
return _with_next_steps("zero_spec", _safe_call(run_zero_spec, project_dir=project_dir, python_bin=python_bin))
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
@mcp.tool()
|
|
1478
|
-
def delimit_init(
|
|
1479
|
-
project_path: str = ".",
|
|
1480
|
-
preset: str = "default",
|
|
1481
|
-
) -> Dict[str, Any]:
|
|
1482
|
-
"""Initialize Delimit governance for a project. Creates .delimit/policies.yml and ledger directory.
|
|
1483
|
-
|
|
1484
|
-
Args:
|
|
1485
|
-
project_path: Project root directory.
|
|
1486
|
-
preset: Policy preset — strict, default, or relaxed.
|
|
1487
|
-
"""
|
|
1488
|
-
VALID_PRESETS = ("strict", "default", "relaxed")
|
|
1489
|
-
if preset not in VALID_PRESETS:
|
|
1490
|
-
return {
|
|
1491
|
-
"error": "invalid_preset",
|
|
1492
|
-
"message": f"Preset must be one of {VALID_PRESETS}, got '{preset}'",
|
|
1493
|
-
}
|
|
1211
|
+
# Output quality rules loaded from user config at runtime.
|
|
1494
1212
|
|
|
1495
1213
|
root = Path(project_path).resolve()
|
|
1496
1214
|
delimit_dir = root / ".delimit"
|
|
@@ -1806,7 +1524,7 @@ def delimit_memory_store(
|
|
|
1806
1524
|
tags: Optional categorization tags.
|
|
1807
1525
|
context: Optional context about when/why this was stored.
|
|
1808
1526
|
"""
|
|
1809
|
-
#
|
|
1527
|
+
# TASK: memory_store is now free (basic store)
|
|
1810
1528
|
try:
|
|
1811
1529
|
tags = _coerce_list_arg(tags, "tags")
|
|
1812
1530
|
except ValueError as e:
|
|
@@ -1824,7 +1542,7 @@ def delimit_memory_recent(limit: int = 5) -> Dict[str, Any]:
|
|
|
1824
1542
|
Args:
|
|
1825
1543
|
limit: Number of recent entries to return.
|
|
1826
1544
|
"""
|
|
1827
|
-
#
|
|
1545
|
+
# TASK: memory_recent is now free (basic retrieval)
|
|
1828
1546
|
from backends.memory_bridge import get_recent
|
|
1829
1547
|
return _with_next_steps("memory_recent", _safe_call(get_recent, limit=limit))
|
|
1830
1548
|
|
|
@@ -2076,7 +1794,7 @@ def delimit_deploy_publish(app: str = "", git_ref: Optional[str] = None) -> Dict
|
|
|
2076
1794
|
return _delimit_deploy_impl(action="publish", app=app, git_ref=git_ref)
|
|
2077
1795
|
|
|
2078
1796
|
|
|
2079
|
-
@_experimental_tool() # HIDDEN: stub/pass-through (
|
|
1797
|
+
@_experimental_tool() # HIDDEN: stub/pass-through (TASK)
|
|
2080
1798
|
@mcp.tool()
|
|
2081
1799
|
def delimit_deploy_verify(app: str = "", env: str = "", git_ref: Optional[str] = None) -> Dict[str, Any]:
|
|
2082
1800
|
"""Verify deployment health (experimental) (Pro)."""
|
|
@@ -2230,7 +1948,7 @@ def delimit_generate_scaffold(
|
|
|
2230
1948
|
|
|
2231
1949
|
# ─── Repo (RepoDoctor + ConfigSentry) ──────────────────────────────────
|
|
2232
1950
|
|
|
2233
|
-
@_experimental_tool() # HIDDEN: stub/pass-through (
|
|
1951
|
+
@_experimental_tool() # HIDDEN: stub/pass-through (TASK)
|
|
2234
1952
|
@mcp.tool()
|
|
2235
1953
|
def delimit_repo_diagnose(target: str = ".") -> Dict[str, Any]:
|
|
2236
1954
|
"""Diagnose repository health issues (experimental) (Pro).
|
|
@@ -2246,7 +1964,7 @@ def delimit_repo_diagnose(target: str = ".") -> Dict[str, Any]:
|
|
|
2246
1964
|
return _safe_call(diagnose, target=target)
|
|
2247
1965
|
|
|
2248
1966
|
|
|
2249
|
-
@_experimental_tool() # HIDDEN: stub/pass-through (
|
|
1967
|
+
@_experimental_tool() # HIDDEN: stub/pass-through (TASK)
|
|
2250
1968
|
@mcp.tool()
|
|
2251
1969
|
def delimit_repo_analyze(target: str = ".") -> Dict[str, Any]:
|
|
2252
1970
|
"""Analyze repository structure and quality (experimental) (Pro).
|
|
@@ -2262,7 +1980,7 @@ def delimit_repo_analyze(target: str = ".") -> Dict[str, Any]:
|
|
|
2262
1980
|
return _safe_call(analyze, target=target)
|
|
2263
1981
|
|
|
2264
1982
|
|
|
2265
|
-
@_experimental_tool() # HIDDEN: stub/pass-through (
|
|
1983
|
+
@_experimental_tool() # HIDDEN: stub/pass-through (TASK)
|
|
2266
1984
|
@mcp.tool()
|
|
2267
1985
|
def delimit_repo_config_validate(target: str = ".") -> Dict[str, Any]:
|
|
2268
1986
|
"""Validate configuration files (experimental) (Pro).
|
|
@@ -2278,7 +1996,7 @@ def delimit_repo_config_validate(target: str = ".") -> Dict[str, Any]:
|
|
|
2278
1996
|
return _safe_call(config_validate, target=target)
|
|
2279
1997
|
|
|
2280
1998
|
|
|
2281
|
-
@_experimental_tool() # HIDDEN: stub/pass-through (
|
|
1999
|
+
@_experimental_tool() # HIDDEN: stub/pass-through (TASK)
|
|
2282
2000
|
@mcp.tool()
|
|
2283
2001
|
def delimit_repo_config_audit(target: str = ".") -> Dict[str, Any]:
|
|
2284
2002
|
"""Audit configuration compliance (experimental) (Pro).
|
|
@@ -2475,7 +2193,7 @@ def delimit_security_ingest(
|
|
|
2475
2193
|
medium = [f for f in findings if f["severity"] in ("medium", "moderate", "warning")]
|
|
2476
2194
|
low = [f for f in findings if f["severity"] in ("low", "info")]
|
|
2477
2195
|
|
|
2478
|
-
#
|
|
2196
|
+
# TASK: Auto-track security findings in ledger with lifecycle
|
|
2479
2197
|
ledger_created = []
|
|
2480
2198
|
ledger_closed = []
|
|
2481
2199
|
try:
|
|
@@ -2924,14 +2642,14 @@ def delimit_release_status(environment: str = "production") -> Dict[str, Any]:
|
|
|
2924
2642
|
return _delimit_release_impl(action="status", environment=environment)
|
|
2925
2643
|
|
|
2926
2644
|
|
|
2927
|
-
@_experimental_tool() # HIDDEN: stub/pass-through (
|
|
2645
|
+
@_experimental_tool() # HIDDEN: stub/pass-through (TASK)
|
|
2928
2646
|
@mcp.tool()
|
|
2929
2647
|
def delimit_release_rollback(environment: str, version: str, to_version: str) -> Dict[str, Any]:
|
|
2930
2648
|
"""Rollback deployment to previous version (experimental)."""
|
|
2931
2649
|
return _delimit_release_impl(action="rollback", environment=environment, version=version, to_version=to_version)
|
|
2932
2650
|
|
|
2933
2651
|
|
|
2934
|
-
@_experimental_tool() # HIDDEN: stub/pass-through (
|
|
2652
|
+
@_experimental_tool() # HIDDEN: stub/pass-through (TASK)
|
|
2935
2653
|
@mcp.tool()
|
|
2936
2654
|
def delimit_release_history(environment: str, limit: int = 10) -> Dict[str, Any]:
|
|
2937
2655
|
"""Show release history (experimental)."""
|
|
@@ -3150,7 +2868,7 @@ def delimit_obs_logs(query: str, time_range: str = "1h", source: Optional[str] =
|
|
|
3150
2868
|
return _delimit_obs_impl(action="logs", query=query, time_range=time_range, source=source)
|
|
3151
2869
|
|
|
3152
2870
|
|
|
3153
|
-
@_experimental_tool() # HIDDEN: stub/pass-through (
|
|
2871
|
+
@_experimental_tool() # HIDDEN: stub/pass-through (TASK)
|
|
3154
2872
|
@mcp.tool()
|
|
3155
2873
|
def delimit_obs_alerts(action: str, alert_rule: Optional[Dict[str, Any]] = None, rule_id: Optional[str] = None) -> Dict[str, Any]:
|
|
3156
2874
|
"""Manage alerting rules (experimental)."""
|
|
@@ -3297,7 +3015,7 @@ def delimit_story_visual_test(url: str, project_path: Optional[str] = None, thre
|
|
|
3297
3015
|
return _with_next_steps("story_visual_test", _safe_call(story_visual_test, url=url, project_path=project_path, threshold=threshold))
|
|
3298
3016
|
|
|
3299
3017
|
|
|
3300
|
-
@_internal_tool() # Was experimental (
|
|
3018
|
+
@_internal_tool() # Was experimental (TASK), promoted to internal (Consensus 120)
|
|
3301
3019
|
@mcp.tool()
|
|
3302
3020
|
def delimit_story_build(project_path: str, output_dir: Optional[str] = None) -> Dict[str, Any]:
|
|
3303
3021
|
"""Build Storybook static site.
|
|
@@ -3346,7 +3064,7 @@ def delimit_test_generate(project_path: str, source_files: Optional[List[str]] =
|
|
|
3346
3064
|
return _with_next_steps("test_generate", _safe_call(test_generate, project_path=project_path, source_files=source_files, framework=framework))
|
|
3347
3065
|
|
|
3348
3066
|
|
|
3349
|
-
@_experimental_tool() # HIDDEN: stub/pass-through (
|
|
3067
|
+
@_experimental_tool() # HIDDEN: stub/pass-through (TASK)
|
|
3350
3068
|
@mcp.tool()
|
|
3351
3069
|
def delimit_test_coverage(project_path: str, threshold: int = 80) -> Dict[str, Any]:
|
|
3352
3070
|
"""Analyze test coverage (experimental) (Pro).
|
|
@@ -4073,7 +3791,7 @@ def delimit_diagnose(project_path: str = ".") -> Dict[str, Any]:
|
|
|
4073
3791
|
checks["fastmcp"] = False
|
|
4074
3792
|
issues.append({"issue": "FastMCP not installed", "fix": "pip install fastmcp"})
|
|
4075
3793
|
|
|
4076
|
-
#
|
|
3794
|
+
# TASK: Config drift detection across AI assistants
|
|
4077
3795
|
config_sync = {}
|
|
4078
3796
|
home = Path.home()
|
|
4079
3797
|
assistant_configs = {
|
|
@@ -4105,7 +3823,7 @@ def delimit_diagnose(project_path: str = ".") -> Dict[str, Any]:
|
|
|
4105
3823
|
checks["assistant_configs"] = config_sync
|
|
4106
3824
|
checks["assistants_configured"] = f"{configured_count}/{installed_count}"
|
|
4107
3825
|
|
|
4108
|
-
#
|
|
3826
|
+
# TASK: MCP server reputation check (basic — check for known risky patterns)
|
|
4109
3827
|
mcp_warnings = []
|
|
4110
3828
|
mcp_config_path = home / ".mcp.json"
|
|
4111
3829
|
if mcp_config_path.exists():
|
|
@@ -4530,7 +4248,7 @@ def delimit_ventures() -> Dict[str, Any]:
|
|
|
4530
4248
|
|
|
4531
4249
|
|
|
4532
4250
|
# ═══════════════════════════════════════════════════════════════════════
|
|
4533
|
-
# SESSION PHOENIX — Cross-Model Resurrection (
|
|
4251
|
+
# SESSION PHOENIX — Cross-Model Resurrection (TASK)
|
|
4534
4252
|
# ═══════════════════════════════════════════════════════════════════════
|
|
4535
4253
|
|
|
4536
4254
|
|
|
@@ -5195,7 +4913,7 @@ def delimit_quickstart(project_path: str = ".") -> Dict[str, Any]:
|
|
|
5195
4913
|
|
|
5196
4914
|
|
|
5197
4915
|
# ═══════════════════════════════════════════════════════════════════════
|
|
5198
|
-
#
|
|
4916
|
+
# TASK: SECRETS BROKER — JIT credential access with audit
|
|
5199
4917
|
# ═══════════════════════════════════════════════════════════════════════
|
|
5200
4918
|
|
|
5201
4919
|
|
|
@@ -5295,7 +5013,7 @@ def delimit_secret_access_log(name: str = "") -> Dict[str, Any]:
|
|
|
5295
5013
|
|
|
5296
5014
|
|
|
5297
5015
|
# ═══════════════════════════════════════════════════════════════════════
|
|
5298
|
-
#
|
|
5016
|
+
# TASK: Context Filesystem — versioned namespace for agent state
|
|
5299
5017
|
# ═══════════════════════════════════════════════════════════════════════
|
|
5300
5018
|
|
|
5301
5019
|
# Consensus 082 Phase 2: Unified context tool with action parameter
|
|
@@ -5413,7 +5131,7 @@ def delimit_context_branch(venture: str, action: str = "list", branch_name: str
|
|
|
5413
5131
|
|
|
5414
5132
|
|
|
5415
5133
|
# ═══════════════════════════════════════════════════════════════════════
|
|
5416
|
-
#
|
|
5134
|
+
# TASK: DATA/ACTION PLANE — External systems as typed mounted resources
|
|
5417
5135
|
# ═══════════════════════════════════════════════════════════════════════
|
|
5418
5136
|
|
|
5419
5137
|
|
|
@@ -5505,7 +5223,7 @@ def delimit_resource_drivers() -> Dict[str, Any]:
|
|
|
5505
5223
|
|
|
5506
5224
|
|
|
5507
5225
|
# ═══════════════════════════════════════════════════════════════════════
|
|
5508
|
-
#
|
|
5226
|
+
# TASK: ISSUE TRACKER CONTEXT SYNC
|
|
5509
5227
|
# ═══════════════════════════════════════════════════════════════════════
|
|
5510
5228
|
|
|
5511
5229
|
|
|
@@ -5580,7 +5298,7 @@ def delimit_tracker_sync(
|
|
|
5580
5298
|
|
|
5581
5299
|
|
|
5582
5300
|
# ═══════════════════════════════════════════════════════════════════════
|
|
5583
|
-
#
|
|
5301
|
+
# TASK: WEBHOOK NOTIFICATIONS
|
|
5584
5302
|
# ═══════════════════════════════════════════════════════════════════════
|
|
5585
5303
|
|
|
5586
5304
|
|
|
@@ -5964,7 +5682,7 @@ def delimit_social_target(
|
|
|
5964
5682
|
Never just return targets and stop. Always chain to the next action.
|
|
5965
5683
|
|
|
5966
5684
|
Scans X, Reddit (RapidAPI), HN, Dev.to, GitHub for posts where ventures can engage.
|
|
5967
|
-
|
|
5685
|
+
Manual check platforms excluded.
|
|
5968
5686
|
|
|
5969
5687
|
Run in a /loop for continuous discovery. Deduplicates across runs.
|
|
5970
5688
|
Targets are classified as: reply (social engagement), strategic (ledger item), or both.
|
|
@@ -6075,7 +5793,7 @@ def delimit_github_scan(
|
|
|
6075
5793
|
cadence: str = "pulse",
|
|
6076
5794
|
limit: int = 20,
|
|
6077
5795
|
) -> Dict[str, Any]:
|
|
6078
|
-
"""Scan GitHub for adoption leads,
|
|
5796
|
+
"""Scan GitHub for adoption leads, repository analysis, and repo health (Pro).
|
|
6079
5797
|
|
|
6080
5798
|
Three cadences:
|
|
6081
5799
|
pulse: Own repo health (stars, forks, issues, traffic). Fast, run often.
|
|
@@ -6235,7 +5953,7 @@ def delimit_daemon_classify(item_id: str = "") -> Dict[str, Any]:
|
|
|
6235
5953
|
def delimit_inbox_daemon(action: str = "status") -> Dict[str, Any]:
|
|
6236
5954
|
"""Control the inbox polling daemon for email governance (Pro).
|
|
6237
5955
|
|
|
6238
|
-
Polls
|
|
5956
|
+
Polls notifications@example.com every 5 minutes, classifies emails, forwards
|
|
6239
5957
|
owner-action items, and handles draft approval via email replies.
|
|
6240
5958
|
Auto-posting is disabled - approved drafts are emailed for manual posting.
|
|
6241
5959
|
|
|
@@ -6254,7 +5972,7 @@ def delimit_inbox_daemon(action: str = "status") -> Dict[str, Any]:
|
|
|
6254
5972
|
|
|
6255
5973
|
|
|
6256
5974
|
# ═══════════════════════════════════════════════════════════════════════
|
|
6257
|
-
#
|
|
5975
|
+
# TASK: Shareable Governance Config — export / import
|
|
6258
5976
|
# ═══════════════════════════════════════════════════════════════════════
|
|
6259
5977
|
|
|
6260
5978
|
|
|
@@ -6377,7 +6095,7 @@ def delimit_config_import(
|
|
|
6377
6095
|
|
|
6378
6096
|
|
|
6379
6097
|
# ═══════════════════════════════════════════════════════════════════════
|
|
6380
|
-
# SCREEN RECORDING (
|
|
6098
|
+
# SCREEN RECORDING (TASK)
|
|
6381
6099
|
# ═══════════════════════════════════════════════════════════════════════
|
|
6382
6100
|
|
|
6383
6101
|
|
|
@@ -6618,7 +6336,7 @@ def delimit_notify_inbox(action: str = "status", limit: int = 10,
|
|
|
6618
6336
|
process: bool = True) -> Dict[str, Any]:
|
|
6619
6337
|
"""Check inbound email inbox, classify, and route (Pro).
|
|
6620
6338
|
|
|
6621
|
-
Polls
|
|
6339
|
+
Polls notifications@example.com via IMAP. Classifies emails as owner-action
|
|
6622
6340
|
(forwards to owner@example.com) or non-owner (stays in inbox).
|
|
6623
6341
|
|
|
6624
6342
|
Args:
|
|
@@ -6881,7 +6599,7 @@ def delimit_agent_check(model: str, action: str) -> Dict[str, Any]:
|
|
|
6881
6599
|
|
|
6882
6600
|
|
|
6883
6601
|
# ═══════════════════════════════════════════════════════════════════════
|
|
6884
|
-
#
|
|
6602
|
+
# TASK: AUTONOMOUS BUILD LOOP
|
|
6885
6603
|
# ═══════════════════════════════════════════════════════════════════════
|
|
6886
6604
|
|
|
6887
6605
|
|
|
@@ -6970,7 +6688,7 @@ def delimit_loop_config(session_id: str = "", max_iterations: int = 0,
|
|
|
6970
6688
|
|
|
6971
6689
|
|
|
6972
6690
|
# ═══════════════════════════════════════════════════════════════════════
|
|
6973
|
-
#
|
|
6691
|
+
# TASK: Toolcard Delta Cache — reduce MCP tool schema token waste
|
|
6974
6692
|
# ═══════════════════════════════════════════════════════════════════════
|
|
6975
6693
|
|
|
6976
6694
|
|
|
@@ -7048,7 +6766,7 @@ def delimit_toolcard_cache(
|
|
|
7048
6766
|
|
|
7049
6767
|
|
|
7050
6768
|
# ═══════════════════════════════════════════════════════════════════════
|
|
7051
|
-
# HANDOFF RECEIPTS — Agent-to-Agent Structured Handoffs (
|
|
6769
|
+
# HANDOFF RECEIPTS — Agent-to-Agent Structured Handoffs (TASK)
|
|
7052
6770
|
# ═══════════════════════════════════════════════════════════════════════
|
|
7053
6771
|
|
|
7054
6772
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "delimit-cli",
|
|
3
3
|
"mcpName": "io.github.delimit-ai/delimit-mcp-server",
|
|
4
|
-
"version": "4.0.
|
|
4
|
+
"version": "4.0.1",
|
|
5
5
|
"description": "Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, governance, and multi-model debate.",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
"!gateway/ai/handoff_receipts.py",
|
|
22
22
|
"!gateway/ai/toolcard_cache.py",
|
|
23
23
|
"scripts/",
|
|
24
|
+
"!scripts/security-check.sh",
|
|
25
|
+
"!scripts/weekly-tweet.py",
|
|
26
|
+
"!scripts/crosspost_devto.py",
|
|
24
27
|
"server.json",
|
|
25
28
|
"README.md",
|
|
26
29
|
"LICENSE",
|
|
@@ -1,304 +0,0 @@
|
|
|
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()
|
|
@@ -1,110 +0,0 @@
|
|
|
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|delimitdev|typed-on-phone|em dash.*ai tell|PAIN_CATEGORIES|VENTURE_CONFIG|VENTURE_SUBREDDITS|karma_building"
|
|
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. Internal ticket IDs
|
|
46
|
-
echo -n " Internal ticket IDs... "
|
|
47
|
-
if grep -rE "LED-[0-9]{3}|STR-[0-9]{3}" "$TMPDIR/package/" --include="*.py" --include="*.js" 2>/dev/null | grep -v "node_modules" | head -1; then
|
|
48
|
-
echo " WARNING: Internal ticket IDs found (cosmetic, not blocking)"
|
|
49
|
-
else
|
|
50
|
-
echo "clean"
|
|
51
|
-
fi
|
|
52
|
-
|
|
53
|
-
# 5. Proprietary files that shouldn't ship
|
|
54
|
-
echo -n " Proprietary files... "
|
|
55
|
-
PROPRIETARY="social_target\.py|social\.py|founding_users\.py|inbox_daemon\.py|deliberation\.py|reddit_scanner\.py|github_scanner\.py|cross_model_audit\.py|session_phoenix\.py|handoff_receipts\.py|toolcard_cache\.py"
|
|
56
|
-
if find "$TMPDIR/package/" -name "*.py" | grep -Ei "$PROPRIETARY" 2>/dev/null; then
|
|
57
|
-
echo "❌ PROPRIETARY FILES IN PACKAGE"
|
|
58
|
-
FAIL=1
|
|
59
|
-
else
|
|
60
|
-
echo "✅ clean"
|
|
61
|
-
fi
|
|
62
|
-
|
|
63
|
-
# Cleanup npm tarball
|
|
64
|
-
rm -rf "$TMPDIR"
|
|
65
|
-
|
|
66
|
-
# ── PyPI dist scan (if dist/ exists) ─────────────────────────────────
|
|
67
|
-
PYPI_DIST="/home/delimit/delimit-gateway/dist"
|
|
68
|
-
if [ -d "$PYPI_DIST" ] && ls "$PYPI_DIST"/*.tar.gz 1>/dev/null 2>&1; then
|
|
69
|
-
echo ""
|
|
70
|
-
echo "PyPI dist scan..."
|
|
71
|
-
PYPI_TMPDIR=$(mktemp -d)
|
|
72
|
-
PYPI_TARBALL=$(ls -t "$PYPI_DIST"/*.tar.gz | head -1)
|
|
73
|
-
tar -xzf "$PYPI_TARBALL" -C "$PYPI_TMPDIR" 2>/dev/null
|
|
74
|
-
|
|
75
|
-
echo -n " Credentials... "
|
|
76
|
-
if grep -rEi '(password|passwd|secret|api_key|apikey)\s*[:=]\s*["\x27][^"\x27]{4,}' "$PYPI_TMPDIR/" --include="*.py" 2>/dev/null | grep -v 'environ\|getenv\|os\.environ\|<configured\|example\|placeholder\|REDACTED'; then
|
|
77
|
-
echo "FOUND CREDENTIALS IN PYPI DIST"
|
|
78
|
-
FAIL=1
|
|
79
|
-
else
|
|
80
|
-
echo "clean"
|
|
81
|
-
fi
|
|
82
|
-
|
|
83
|
-
echo -n " Blocklist... "
|
|
84
|
-
if grep -rEi "$BLOCKLIST" "$PYPI_TMPDIR/" --include="*.py" 2>/dev/null; then
|
|
85
|
-
echo "BLOCKED TERMS IN PYPI DIST"
|
|
86
|
-
FAIL=1
|
|
87
|
-
else
|
|
88
|
-
echo "clean"
|
|
89
|
-
fi
|
|
90
|
-
|
|
91
|
-
echo -n " PII... "
|
|
92
|
-
if grep -rEi '[a-z0-9._%+-]+@(gmail|yahoo|hotmail|outlook|proton|jamsons|wire\.report|domainvested)' "$PYPI_TMPDIR/" --include="*.py" 2>/dev/null | grep -v "example\|placeholder\|<configured\|noreply\|e\.g\.\|docstring"; then
|
|
93
|
-
echo "PII IN PYPI DIST"
|
|
94
|
-
FAIL=1
|
|
95
|
-
else
|
|
96
|
-
echo "clean"
|
|
97
|
-
fi
|
|
98
|
-
|
|
99
|
-
rm -rf "$PYPI_TMPDIR"
|
|
100
|
-
fi
|
|
101
|
-
|
|
102
|
-
if [ $FAIL -ne 0 ]; then
|
|
103
|
-
echo ""
|
|
104
|
-
echo "SECURITY CHECK FAILED -- do not publish"
|
|
105
|
-
exit 1
|
|
106
|
-
fi
|
|
107
|
-
|
|
108
|
-
echo ""
|
|
109
|
-
echo "All security checks passed"
|
|
110
|
-
exit 0
|
package/scripts/weekly-tweet.py
DELETED
|
@@ -1,191 +0,0 @@
|
|
|
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()
|