openwriter 0.25.0 → 0.27.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,792 @@
1
+ /**
2
+ * Blog publishing MCP tools — local git ops, no Worker, no PAT.
3
+ *
4
+ * Auth: piggybacks on the user's existing `gh auth login`.
5
+ * Persistence: blogSites in plugins['@openwriter/plugin-github'].blogSites.
6
+ */
7
+ import { execFile } from 'child_process';
8
+ import { existsSync, mkdirSync, readFileSync, copyFileSync, writeFileSync, readdirSync, statSync, rmSync } from 'fs';
9
+ import { join, extname, dirname } from 'path';
10
+ import { randomUUID } from 'crypto';
11
+ import { homedir } from 'os';
12
+ import { getServerModules, listBlogSites, writeBlogSites, } from './helpers.js';
13
+ const NETWORK_TIMEOUT = 60000;
14
+ function exec(cmd, args, cwd, timeout = NETWORK_TIMEOUT) {
15
+ const safeArgs = args.map(a => a.includes(' ') ? `"${a}"` : a);
16
+ return new Promise((resolve, reject) => {
17
+ execFile(cmd, safeArgs, { cwd, shell: true, timeout, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
18
+ if (err)
19
+ reject(new Error(stderr?.trim() || err.message));
20
+ else
21
+ resolve(stdout.trim());
22
+ });
23
+ });
24
+ }
25
+ async function ghAuthOk(cwd) {
26
+ try {
27
+ await exec('gh', ['auth', 'status'], cwd);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ function slugify(s) {
35
+ return s.toLowerCase().replace(/['"]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
36
+ }
37
+ function owRoot() {
38
+ return join(homedir(), '.openwriter');
39
+ }
40
+ // ---- inspect_blog_repo helpers ----
41
+ function walkDir(root, max = 5000) {
42
+ const out = [];
43
+ const queue = [root];
44
+ while (queue.length && out.length < max) {
45
+ const dir = queue.shift();
46
+ let entries;
47
+ try {
48
+ entries = readdirSync(dir);
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ for (const name of entries) {
54
+ if (name === '.git' || name === 'node_modules')
55
+ continue;
56
+ const full = join(dir, name);
57
+ let st;
58
+ try {
59
+ st = statSync(full);
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ if (st.isDirectory())
65
+ queue.push(full);
66
+ else
67
+ out.push(full);
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+ function dirOfMostMarkdown(files, cloneRoot) {
73
+ const counts = new Map();
74
+ for (const f of files) {
75
+ if (extname(f).toLowerCase() !== '.md' && extname(f).toLowerCase() !== '.mdx')
76
+ continue;
77
+ const rel = f.slice(cloneRoot.length + 1).replace(/\\/g, '/');
78
+ const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '';
79
+ // Skip README.md at root, etc.
80
+ if (!dir)
81
+ continue;
82
+ counts.set(dir, (counts.get(dir) || 0) + 1);
83
+ }
84
+ let best = null;
85
+ for (const [dir, count] of counts) {
86
+ if (!best || count > best.count)
87
+ best = { dir, count };
88
+ }
89
+ return best;
90
+ }
91
+ /**
92
+ * Parse one frontmatter block into key→raw-string-value pairs.
93
+ * Top-level only — array-on-next-line and nested objects are returned as
94
+ * "<multiline>" sentinel so they're treated as varying.
95
+ */
96
+ function parseYamlFrontmatter(raw) {
97
+ const m = raw.match(/^---\s*\n([\s\S]*?)\n---/);
98
+ if (!m)
99
+ return {};
100
+ const out = {};
101
+ const lines = m[1].split('\n');
102
+ for (let i = 0; i < lines.length; i++) {
103
+ const line = lines[i];
104
+ const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
105
+ if (!kv)
106
+ continue;
107
+ const [, key, rest] = kv;
108
+ const trimmed = rest.trim();
109
+ if (trimmed === '' || trimmed === '|' || trimmed === '>') {
110
+ // Multi-line scalar / list-on-next-line — mark as variable
111
+ out[key] = '<multiline>';
112
+ continue;
113
+ }
114
+ out[key] = trimmed;
115
+ }
116
+ return out;
117
+ }
118
+ /**
119
+ * Detect frontmatter that was clearly written by an older version of the
120
+ * openwriter github plugin (and therefore leaks openwriter-internal fields).
121
+ * Those files would otherwise pollute the constant-detection across samples,
122
+ * so the inspector excludes them.
123
+ *
124
+ * Fingerprint markers (any one is sufficient):
125
+ * - `enrichmentStale` field present (openwriter-only)
126
+ * - `status: draft` + `slug` + `date:` with ISO-with-time (old plugin's emit)
127
+ * - top-level `tags` set to a single openwriter content-type token
128
+ * (e.g. `tags: [blog]`)
129
+ */
130
+ function looksLikeOpenwriterLeak(fm) {
131
+ if ('enrichmentStale' in fm)
132
+ return true;
133
+ if (fm.status === 'draft' && 'slug' in fm && fm.date && /T\d{2}:\d{2}/.test(fm.date)) {
134
+ return true;
135
+ }
136
+ const t = fm.tags;
137
+ if (t && /^\[\s*["']?(blog|tweet|article|linkedin|newsletter)["']?\s*\]$/i.test(t.trim())) {
138
+ return true;
139
+ }
140
+ return false;
141
+ }
142
+ /**
143
+ * Inspect multiple sample post frontmatters and propose:
144
+ * - `frontmatter_defaults`: fields with the SAME value across all posts
145
+ * - `frontmatter_field_map`: rename map from openwriter standard names
146
+ * to whatever the site actually uses (e.g. `date` → `publishedDate`)
147
+ * - `frontmatter_schema`: union of all keys seen
148
+ *
149
+ * Files that look like openwriter-leak frontmatter (the OLD plugin's
150
+ * output) are excluded from analysis so they don't poison the constants.
151
+ *
152
+ * The detection is conservative — only fields present in ≥2 samples with
153
+ * an identical value become defaults. Single-sample fields are skipped
154
+ * to avoid baking per-post variations in.
155
+ */
156
+ function inferFrontmatterShape(rawSamples) {
157
+ // Filter out openwriter-leak files so they don't pollute defaults detection
158
+ const samples = rawSamples.filter((s) => !looksLikeOpenwriterLeak(s));
159
+ if (samples.length === 0)
160
+ return { defaults: {}, field_map: {}, schema: [] };
161
+ // Schema = union of all keys (preserve insertion order from first sample)
162
+ const schemaOrder = [];
163
+ const seen = new Set();
164
+ for (const s of samples) {
165
+ for (const k of Object.keys(s)) {
166
+ if (!seen.has(k)) {
167
+ seen.add(k);
168
+ schemaOrder.push(k);
169
+ }
170
+ }
171
+ }
172
+ // Defaults: key present in ALL samples with identical, non-empty,
173
+ // non-multiline value
174
+ const defaults = {};
175
+ for (const k of schemaOrder) {
176
+ const values = samples.map((s) => s[k]);
177
+ if (values.some((v) => v === undefined))
178
+ continue;
179
+ if (values.some((v) => v === '<multiline>'))
180
+ continue;
181
+ const first = values[0];
182
+ if (!first)
183
+ continue;
184
+ if (!values.every((v) => v === first))
185
+ continue;
186
+ // Skip per-post fields that are NEVER constants in practice
187
+ if (['title', 'description', 'slug', 'date', 'publishedDate', 'pubDate', 'coverImage', 'coverImageAlt', 'tags', 'category', 'categories'].includes(k))
188
+ continue;
189
+ // Unquote double-quoted strings
190
+ const unq = first.match(/^"(.*)"$/);
191
+ let parsed = unq ? unq[1] : first;
192
+ if (parsed === 'true')
193
+ parsed = true;
194
+ else if (parsed === 'false')
195
+ parsed = false;
196
+ else if (/^-?\d+$/.test(parsed))
197
+ parsed = Number(parsed);
198
+ defaults[k] = parsed;
199
+ }
200
+ // Field map: detect which date field the site uses
201
+ const field_map = {};
202
+ if (seen.has('publishedDate') && !seen.has('date')) {
203
+ field_map.date = 'publishedDate';
204
+ }
205
+ else if (seen.has('pubDate') && !seen.has('date')) {
206
+ field_map.date = 'pubDate';
207
+ }
208
+ return { defaults, field_map, schema: schemaOrder };
209
+ }
210
+ /**
211
+ * Detect the site's public URL from common static-host conventions:
212
+ * - `CNAME` at repo root or `public/CNAME` (GitHub Pages / Cloudflare Pages / Netlify)
213
+ * - `wrangler.toml` `routes` (Cloudflare Workers)
214
+ * - `netlify.toml` `[[redirects]]` to= field with a full URL
215
+ * - GitHub Pages default `<owner>.github.io/<repo>/` is NOT proposed — too often wrong
216
+ * when a custom domain is in play; user can fill in if they want it.
217
+ */
218
+ function inferSiteUrl(cloneRoot) {
219
+ const tryRead = (rel) => {
220
+ const p = join(cloneRoot, rel);
221
+ if (!existsSync(p))
222
+ return undefined;
223
+ try {
224
+ return readFileSync(p, 'utf-8').trim();
225
+ }
226
+ catch {
227
+ return undefined;
228
+ }
229
+ };
230
+ // CNAME files (single line with domain, no scheme)
231
+ for (const rel of ['CNAME', 'public/CNAME', 'static/CNAME', 'src/CNAME']) {
232
+ const v = tryRead(rel);
233
+ if (v) {
234
+ const host = v.split('\n')[0].trim().replace(/^https?:\/\//, '').replace(/\/+$/, '');
235
+ if (/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(host))
236
+ return `https://${host}`;
237
+ }
238
+ }
239
+ // wrangler.toml — look for `routes = ["https://..."]` or `route = "..."`
240
+ const wrangler = tryRead('wrangler.toml');
241
+ if (wrangler) {
242
+ const m = wrangler.match(/route[s]?\s*=\s*\[?\s*["']https?:\/\/([^"'/*]+)/);
243
+ if (m)
244
+ return `https://${m[1].replace(/\/$/, '')}`;
245
+ }
246
+ return undefined;
247
+ }
248
+ function inferFramework(cloneRoot, files) {
249
+ const has = (rel) => existsSync(join(cloneRoot, rel));
250
+ if (has('astro.config.mjs') || has('astro.config.ts') || has('astro.config.js') ||
251
+ files.some(f => /astro\.config\.(mjs|ts|js)$/.test(f)))
252
+ return 'astro';
253
+ if (has('next.config.js') || has('next.config.mjs') || has('next.config.ts'))
254
+ return 'next';
255
+ if (has('_config.yml'))
256
+ return 'jekyll';
257
+ if (has('hugo.toml') || has('config.toml') || has('hugo.yaml') || has('config.yaml'))
258
+ return 'hugo';
259
+ return 'unknown';
260
+ }
261
+ function defaultDirsForFramework(fw, detectedContentDir) {
262
+ switch (fw) {
263
+ case 'astro':
264
+ return {
265
+ content_dir: detectedContentDir || 'src/content/blog',
266
+ image_dir: 'public/blog-images',
267
+ image_public_prefix: '/blog-images',
268
+ };
269
+ case 'next':
270
+ return {
271
+ content_dir: detectedContentDir || 'posts',
272
+ image_dir: 'public/blog-images',
273
+ image_public_prefix: '/blog-images',
274
+ };
275
+ case 'jekyll':
276
+ return {
277
+ content_dir: '_posts',
278
+ image_dir: 'assets/images',
279
+ image_public_prefix: '/assets/images',
280
+ };
281
+ case 'hugo':
282
+ return {
283
+ content_dir: detectedContentDir || 'content/posts',
284
+ image_dir: 'static/images',
285
+ image_public_prefix: '/images',
286
+ };
287
+ default:
288
+ return {
289
+ content_dir: detectedContentDir || 'posts',
290
+ image_dir: 'public/images',
291
+ image_public_prefix: '/images',
292
+ };
293
+ }
294
+ }
295
+ // ---- post_to_blog helpers ----
296
+ /**
297
+ * Strict double-quoted YAML emission for scalars — matches the style of
298
+ * caloriebot's existing posts. Arrays are inline-square-bracket JSON for
299
+ * compactness. Booleans + numbers emit bare.
300
+ */
301
+ function yamlValue(v) {
302
+ if (v == null)
303
+ return '""';
304
+ if (typeof v === 'boolean' || typeof v === 'number')
305
+ return String(v);
306
+ if (Array.isArray(v))
307
+ return '[' + v.map((x) => yamlValue(x)).join(', ') + ']';
308
+ if (typeof v === 'object')
309
+ return JSON.stringify(v);
310
+ return JSON.stringify(String(v));
311
+ }
312
+ /**
313
+ * Format a date for frontmatter. If the value already matches YYYY-MM-DD,
314
+ * pass through; if it's an ISO string with time, slice to date only;
315
+ * otherwise return as-is.
316
+ */
317
+ function formatDate(v) {
318
+ if (typeof v !== 'string')
319
+ return String(v ?? '');
320
+ const m = v.match(/^(\d{4}-\d{2}-\d{2})/);
321
+ return m ? m[1] : v;
322
+ }
323
+ /**
324
+ * Build the YAML frontmatter from blogContext + site defaults.
325
+ *
326
+ * Order of precedence (low → high):
327
+ * 1. Site `frontmatter_defaults` (e.g. `layout`, `author`, `prerender`)
328
+ * 2. Generated `title` (from document title — always present)
329
+ * 3. blogContext fields (description, date, author, tags, slug, draft, coverImage)
330
+ *
331
+ * Field-name mapping: blogContext keys are renamed via `site.frontmatter_field_map`
332
+ * before emit (e.g. `date` → `publishedDate` for Astro sites).
333
+ *
334
+ * Top-level openwriter metadata (status, enrichmentStale, tags-as-content-type,
335
+ * etc.) is NEVER passed through. Frontmatter is built ONLY from blogContext +
336
+ * defaults — this is the design contract from server/blog-routes.ts.
337
+ */
338
+ function buildFrontmatter(title, blogCtx, site, coverImagePath) {
339
+ const fm = {};
340
+ const map = site.frontmatter_field_map || {};
341
+ // 1. Site defaults (lowest priority — overridable below)
342
+ if (site.frontmatter_defaults) {
343
+ for (const [k, v] of Object.entries(site.frontmatter_defaults)) {
344
+ fm[k] = v;
345
+ }
346
+ }
347
+ // 2. Title (always)
348
+ fm.title = title;
349
+ // 3. blogContext fields — apply field_map rename, skip empty values
350
+ const passthrough = [
351
+ { src: 'description' },
352
+ { src: 'date', format: formatDate },
353
+ { src: 'author' },
354
+ { src: 'tags' },
355
+ { src: 'category' },
356
+ { src: 'slug' },
357
+ { src: 'draft' },
358
+ { src: 'subtitle' },
359
+ { src: 'excerpt' },
360
+ { src: 'coverImageAlt' },
361
+ ];
362
+ for (const { src, format } of passthrough) {
363
+ const v = blogCtx[src];
364
+ if (v == null || v === '' || (Array.isArray(v) && v.length === 0))
365
+ continue;
366
+ const dest = map[src] || src;
367
+ fm[dest] = format ? format(v) : v;
368
+ }
369
+ // 4. Cover image: rewritten path takes priority over blogContext.coverImage
370
+ if (coverImagePath) {
371
+ const dest = map.coverImage || 'coverImage';
372
+ fm[dest] = coverImagePath;
373
+ }
374
+ // Ensure date field exists if site expects one — derive from today
375
+ const dateDest = map.date || 'date';
376
+ const publishedDateDest = map.publishedDate || (map.date === 'publishedDate' ? 'publishedDate' : null);
377
+ if (!fm[dateDest] && !(publishedDateDest && fm[publishedDateDest])) {
378
+ fm[dateDest] = new Date().toISOString().slice(0, 10);
379
+ }
380
+ // Emit in stable order: defaults first (in their declared order),
381
+ // then title, then any new keys we added
382
+ const lines = [];
383
+ const written = new Set();
384
+ if (site.frontmatter_defaults) {
385
+ for (const k of Object.keys(site.frontmatter_defaults)) {
386
+ if (k in fm) {
387
+ lines.push(`${k}: ${yamlValue(fm[k])}`);
388
+ written.add(k);
389
+ }
390
+ }
391
+ }
392
+ if (!written.has('title')) {
393
+ lines.push(`title: ${yamlValue(fm.title)}`);
394
+ written.add('title');
395
+ }
396
+ for (const [k, v] of Object.entries(fm)) {
397
+ if (written.has(k))
398
+ continue;
399
+ lines.push(`${k}: ${yamlValue(v)}`);
400
+ written.add(k);
401
+ }
402
+ return `---\n${lines.join('\n')}\n---\n\n`;
403
+ }
404
+ function stripFrontmatter(md) {
405
+ return md.replace(/^---\n[\s\S]*?\n---\n+/, '').replace(/^\s*<!--\s*-->\s*$/gm, '').trim();
406
+ }
407
+ // ---- Tools ----
408
+ export function blogTools() {
409
+ return [
410
+ {
411
+ name: 'inspect_blog_repo',
412
+ description: 'Clone a GitHub blog repo (shallow) to a local cache and infer its framework, content directory, image directory, and frontmatter schema. Read-only — produces a config preview you can feed to add_blog_site.',
413
+ inputSchema: {
414
+ type: 'object',
415
+ properties: {
416
+ repo_url: { type: 'string', description: 'GitHub repo URL or owner/repo shorthand (e.g. "https://github.com/user/blog" or "user/blog")' },
417
+ },
418
+ required: ['repo_url'],
419
+ },
420
+ handler: async (params) => {
421
+ const repoUrl = String(params.repo_url || '').trim();
422
+ if (!repoUrl)
423
+ return { error: 'repo_url is required' };
424
+ // Normalize to owner/repo
425
+ let ownerRepo = repoUrl
426
+ .replace(/^https?:\/\/github\.com\//, '')
427
+ .replace(/\.git$/, '')
428
+ .replace(/^github:/, '');
429
+ const parts = ownerRepo.split('/').filter(Boolean);
430
+ if (parts.length < 2)
431
+ return { error: 'Could not parse owner/repo from repo_url' };
432
+ const owner = parts[0];
433
+ const repo = parts[1];
434
+ const cacheRoot = join(owRoot(), '_blog-inspect-cache');
435
+ mkdirSync(cacheRoot, { recursive: true });
436
+ const cloneDir = join(cacheRoot, `${owner}-${repo}`);
437
+ if (!(await ghAuthOk(cacheRoot))) {
438
+ return { error: 'gh CLI not authenticated. Run `gh auth login` first.' };
439
+ }
440
+ // Refresh: remove existing then clone shallow
441
+ if (existsSync(cloneDir)) {
442
+ try {
443
+ rmSync(cloneDir, { recursive: true, force: true });
444
+ }
445
+ catch { /* ignore */ }
446
+ }
447
+ try {
448
+ await exec('gh', ['repo', 'clone', `${owner}/${repo}`, cloneDir, '--', '--depth', '1'], cacheRoot);
449
+ }
450
+ catch (err) {
451
+ return { error: `Clone failed: ${err.message}` };
452
+ }
453
+ const files = walkDir(cloneDir);
454
+ const framework = inferFramework(cloneDir, files);
455
+ const mdBest = dirOfMostMarkdown(files, cloneDir);
456
+ const detected = mdBest?.dir || '';
457
+ const defaults = defaultDirsForFramework(framework, detected);
458
+ // Collect multiple sample post frontmatters so we can detect constants.
459
+ // Up to 10 samples to avoid pathological loops on huge repos.
460
+ const sampleFiles = files
461
+ .filter((f) => {
462
+ const rel = f.slice(cloneDir.length + 1).replace(/\\/g, '/');
463
+ return (detected ? rel.startsWith(detected + '/') : true) && /\.(md|mdx)$/i.test(rel);
464
+ })
465
+ .slice(0, 10);
466
+ const rawSamples = [];
467
+ for (const f of sampleFiles) {
468
+ try {
469
+ rawSamples.push(parseYamlFrontmatter(readFileSync(f, 'utf-8')));
470
+ }
471
+ catch { /* skip */ }
472
+ }
473
+ const samplesAfterFilter = rawSamples.filter((s) => !looksLikeOpenwriterLeak(s));
474
+ const samplesSkipped = rawSamples.length - samplesAfterFilter.length;
475
+ const shape = inferFrontmatterShape(rawSamples);
476
+ const confidence = framework !== 'unknown' && detected ? 'high'
477
+ : detected ? 'medium'
478
+ : 'low';
479
+ const siteUrl = inferSiteUrl(cloneDir);
480
+ return {
481
+ owner,
482
+ repo,
483
+ framework,
484
+ content_dir: defaults.content_dir,
485
+ image_dir: defaults.image_dir,
486
+ image_public_prefix: defaults.image_public_prefix,
487
+ frontmatter_schema: shape.schema,
488
+ frontmatter_defaults: shape.defaults,
489
+ frontmatter_field_map: shape.field_map,
490
+ // Always propose a pattern even when site_url is unknown so the user can fill in the URL
491
+ site_url: siteUrl,
492
+ blog_url_pattern: '/blog/{slug}/',
493
+ samples_analyzed: samplesAfterFilter.length,
494
+ samples_skipped_openwriter_leak: samplesSkipped,
495
+ markdown_files_found: mdBest?.count ?? 0,
496
+ confidence,
497
+ };
498
+ },
499
+ },
500
+ {
501
+ name: 'add_blog_site',
502
+ description: 'Register a GitHub blog repo as a publishing target. Use inspect_blog_repo first to discover sensible defaults — including the frontmatter_defaults and frontmatter_field_map it proposes, which match what the site\'s existing posts use.',
503
+ inputSchema: {
504
+ type: 'object',
505
+ properties: {
506
+ label: { type: 'string', description: 'User-facing name (e.g. "Personal blog")' },
507
+ owner: { type: 'string', description: 'GitHub owner/user/org' },
508
+ repo: { type: 'string', description: 'Repo name' },
509
+ branch: { type: 'string', description: 'Branch to push to (default: main)' },
510
+ content_dir: { type: 'string', description: 'Directory where post .md files live (e.g. "src/content/blog")' },
511
+ image_dir: { type: 'string', description: 'Directory where image files write (e.g. "public/blog-images")' },
512
+ image_public_prefix: { type: 'string', description: 'URL prefix for images in markdown (e.g. "/blog-images")' },
513
+ framework: { type: 'string', enum: ['astro', 'next', 'jekyll', 'hugo', 'unknown'], description: 'Site framework' },
514
+ frontmatter_defaults: {
515
+ type: 'object',
516
+ description: 'Constants applied to every post\'s frontmatter (e.g. `{ "layout": "../../layouts/BlogPost.astro", "author": "...", "prerender": true }`). Detected by inspect_blog_repo as fields with constant values across existing posts.',
517
+ },
518
+ frontmatter_field_map: {
519
+ type: 'object',
520
+ description: 'Rename map: openwriter blogContext key → site frontmatter key (e.g. `{ "date": "publishedDate" }` for Astro-style sites that use `publishedDate`).',
521
+ },
522
+ frontmatter_schema: {
523
+ type: 'array',
524
+ items: { type: 'string' },
525
+ description: 'List of frontmatter keys the site uses (from inspection). Stored for reference / future UI.',
526
+ },
527
+ site_url: {
528
+ type: 'string',
529
+ description: 'Public base URL of the site (e.g. "https://example.com"). Used to construct the live URL surfaced after publish. inspect_blog_repo proposes this from CNAME / wrangler.toml when found.',
530
+ },
531
+ blog_url_pattern: {
532
+ type: 'string',
533
+ description: 'URL path pattern for a blog post with `{slug}` placeholder (e.g. "/blog/{slug}/"). Default: "/blog/{slug}/". Combined with site_url to build the live URL stored on the doc after publish.',
534
+ },
535
+ },
536
+ required: ['label', 'owner', 'repo', 'content_dir', 'image_dir', 'image_public_prefix', 'framework'],
537
+ },
538
+ handler: async (params) => {
539
+ const site = {
540
+ id: randomUUID(),
541
+ label: String(params.label),
542
+ owner: String(params.owner),
543
+ repo: String(params.repo),
544
+ branch: String(params.branch || 'main'),
545
+ content_dir: String(params.content_dir),
546
+ image_dir: String(params.image_dir),
547
+ image_public_prefix: String(params.image_public_prefix),
548
+ framework: params.framework || 'unknown',
549
+ };
550
+ if (params.frontmatter_defaults && typeof params.frontmatter_defaults === 'object') {
551
+ site.frontmatter_defaults = params.frontmatter_defaults;
552
+ }
553
+ if (params.frontmatter_field_map && typeof params.frontmatter_field_map === 'object') {
554
+ site.frontmatter_field_map = params.frontmatter_field_map;
555
+ }
556
+ if (Array.isArray(params.frontmatter_schema)) {
557
+ site.frontmatter_schema = params.frontmatter_schema.map(String);
558
+ }
559
+ if (typeof params.site_url === 'string' && params.site_url.trim()) {
560
+ site.site_url = params.site_url.trim().replace(/\/+$/, '');
561
+ }
562
+ if (typeof params.blog_url_pattern === 'string' && params.blog_url_pattern.trim()) {
563
+ site.blog_url_pattern = params.blog_url_pattern.trim();
564
+ }
565
+ const sites = await listBlogSites();
566
+ sites.push(site);
567
+ await writeBlogSites(sites);
568
+ return { success: true, site };
569
+ },
570
+ },
571
+ {
572
+ name: 'list_blog_sites',
573
+ description: 'List all registered GitHub blog sites.',
574
+ inputSchema: { type: 'object', properties: {} },
575
+ handler: async () => {
576
+ const sites = await listBlogSites();
577
+ return { sites };
578
+ },
579
+ },
580
+ {
581
+ name: 'remove_blog_site',
582
+ description: 'Remove a registered blog site by id.',
583
+ inputSchema: {
584
+ type: 'object',
585
+ properties: {
586
+ id: { type: 'string', description: 'Blog site id (from list_blog_sites)' },
587
+ },
588
+ required: ['id'],
589
+ },
590
+ handler: async (params) => {
591
+ const id = String(params.id);
592
+ const sites = await listBlogSites();
593
+ const next = sites.filter(s => s.id !== id);
594
+ if (next.length === sites.length)
595
+ return { error: `No blog site with id ${id}` };
596
+ await writeBlogSites(next);
597
+ return { success: true, removed: id };
598
+ },
599
+ },
600
+ {
601
+ name: 'post_to_blog',
602
+ description: 'Publish the active OpenWriter document to a registered GitHub blog site via local git ops (clone-or-pull, write file + images, commit, push). Auth uses your existing `gh auth login`.',
603
+ inputSchema: {
604
+ type: 'object',
605
+ properties: {
606
+ site_id: { type: 'string', description: 'Blog site id (from list_blog_sites)' },
607
+ slug: { type: 'string', description: 'Filename slug (without .md). Default: slugified document title.' },
608
+ commit_message: { type: 'string', description: 'Git commit message. Default: "blog: {title}".' },
609
+ },
610
+ required: ['site_id'],
611
+ },
612
+ handler: async (params) => {
613
+ const siteId = String(params.site_id);
614
+ const sites = await listBlogSites();
615
+ const site = sites.find(s => s.id === siteId);
616
+ if (!site)
617
+ return { error: `No blog site with id ${siteId}` };
618
+ const srv = await getServerModules();
619
+ // Flush any pending writes so we read fresh state
620
+ try {
621
+ srv.cancelDebouncedSave();
622
+ srv.save();
623
+ }
624
+ catch { /* ignore */ }
625
+ const doc = srv.getDocument();
626
+ const title = srv.getTitle();
627
+ const metadata = srv.getMetadata() || {};
628
+ if (!doc || !doc.content)
629
+ return { error: 'No active document. Switch to a document first.' };
630
+ if (!title)
631
+ return { error: 'Document has no title. Set a title before publishing.' };
632
+ const contentType = metadata.content_type ||
633
+ (metadata.tweetContext ? 'tweet'
634
+ : metadata.articleContext ? 'article'
635
+ : metadata.linkedinContext ? 'linkedin'
636
+ : metadata.newsletterContext ? 'newsletter'
637
+ : metadata.blogContext ? 'blog'
638
+ : undefined);
639
+ if (contentType !== 'blog') {
640
+ return {
641
+ error: `Active document is content_type "${contentType || 'untyped'}", not "blog". post_to_blog only publishes blog docs. Create a blog doc (sidebar → "+ New" → Blog) and switch to it before posting.`,
642
+ };
643
+ }
644
+ const blogCtx = metadata.blogContext || {};
645
+ const cloneRoot = join(owRoot(), '_blog-clones');
646
+ mkdirSync(cloneRoot, { recursive: true });
647
+ const clonePath = join(cloneRoot, site.id);
648
+ if (!(await ghAuthOk(cloneRoot))) {
649
+ return { error: 'gh CLI not authenticated. Run `gh auth login` first.' };
650
+ }
651
+ // Clone or refresh
652
+ if (!existsSync(join(clonePath, '.git'))) {
653
+ if (existsSync(clonePath)) {
654
+ try {
655
+ rmSync(clonePath, { recursive: true, force: true });
656
+ }
657
+ catch { /* ignore */ }
658
+ }
659
+ try {
660
+ await exec('gh', ['repo', 'clone', `${site.owner}/${site.repo}`, clonePath], cloneRoot);
661
+ }
662
+ catch (err) {
663
+ return { error: `Clone failed: ${err.message}` };
664
+ }
665
+ try {
666
+ await exec('git', ['checkout', site.branch], clonePath);
667
+ }
668
+ catch { /* may already be on it */ }
669
+ }
670
+ else {
671
+ try {
672
+ await exec('git', ['fetch', 'origin', site.branch], clonePath);
673
+ await exec('git', ['checkout', site.branch], clonePath);
674
+ await exec('git', ['reset', '--hard', `origin/${site.branch}`], clonePath);
675
+ }
676
+ catch (err) {
677
+ return { error: `Failed to refresh clone: ${err.message}` };
678
+ }
679
+ }
680
+ // Build markdown body
681
+ const rawMd = srv.tiptapToMarkdown(doc, title, metadata);
682
+ const bodyMd = stripFrontmatter(rawMd);
683
+ // Slug priority: explicit param > blogContext.slug > slugified title
684
+ const slug = String(params.slug || blogCtx.slug || slugify(title));
685
+ if (!slug)
686
+ return { error: 'Could not derive a slug from the title.' };
687
+ // Rewrite inline image refs in body, collect filenames
688
+ const imgPrefix = site.image_public_prefix.replace(/\/+$/, '');
689
+ const imageRefs = new Set();
690
+ const bodyRewritten = bodyMd.replace(/\/_images\/([^\s)"'<>]+)/g, (_m, fn) => {
691
+ imageRefs.add(fn);
692
+ return `${imgPrefix}/${fn}`;
693
+ });
694
+ // Handle cover image from blogContext
695
+ let coverImagePath;
696
+ if (typeof blogCtx.coverImage === 'string' && blogCtx.coverImage) {
697
+ const coverFile = blogCtx.coverImage.replace(/^\/_images\//, '');
698
+ imageRefs.add(coverFile);
699
+ coverImagePath = `${imgPrefix}/${coverFile}`;
700
+ }
701
+ // Copy images
702
+ const dataDir = srv.getDataDir();
703
+ const imageDirAbs = join(clonePath, site.image_dir);
704
+ mkdirSync(imageDirAbs, { recursive: true });
705
+ let imagesCopied = 0;
706
+ for (const fn of imageRefs) {
707
+ const src = join(dataDir, '_images', fn);
708
+ if (!existsSync(src))
709
+ continue;
710
+ const dst = join(imageDirAbs, fn);
711
+ mkdirSync(dirname(dst), { recursive: true });
712
+ copyFileSync(src, dst);
713
+ imagesCopied++;
714
+ }
715
+ // Write the post file
716
+ const frontmatter = buildFrontmatter(title, blogCtx, site, coverImagePath);
717
+ const postRel = join(site.content_dir, `${slug}.md`);
718
+ const postAbs = join(clonePath, postRel);
719
+ mkdirSync(dirname(postAbs), { recursive: true });
720
+ writeFileSync(postAbs, frontmatter + bodyRewritten + '\n', 'utf-8');
721
+ // Commit + push (no-op is fine — still counts as a publish for the writeback)
722
+ const commitMessage = String(params.commit_message || `blog: ${title}`);
723
+ let noChanges = false;
724
+ try {
725
+ await exec('git', ['add', '-A'], clonePath);
726
+ const status = await exec('git', ['status', '--porcelain'], clonePath);
727
+ if (!status) {
728
+ noChanges = true;
729
+ }
730
+ else {
731
+ await exec('git', ['commit', '-m', commitMessage], clonePath);
732
+ await exec('git', ['push', 'origin', site.branch], clonePath);
733
+ }
734
+ }
735
+ catch (err) {
736
+ return { error: `Git op failed: ${err.message}` };
737
+ }
738
+ let shortHash = '';
739
+ try {
740
+ shortHash = await exec('git', ['rev-parse', '--short', 'HEAD'], clonePath);
741
+ }
742
+ catch { /* ignore */ }
743
+ // Construct the live URL when the site has site_url configured.
744
+ // Pattern defaults to /blog/{slug}/ — matches the convention proposed by inspect_blog_repo.
745
+ let liveUrl;
746
+ if (site.site_url) {
747
+ const pattern = site.blog_url_pattern || '/blog/{slug}/';
748
+ const path = pattern.replace('{slug}', slug);
749
+ liveUrl = site.site_url.replace(/\/+$/, '') + (path.startsWith('/') ? path : '/' + path);
750
+ }
751
+ // Mark the doc as sent so the file-tree right-click menu surfaces
752
+ // "View Post" with a live link. This mirrors the tweetContext.lastPost /
753
+ // articleContext.lastPost / newsletterContext.lastSend pattern. Runs
754
+ // even on no-op publishes — the doc is still "on the site as of now".
755
+ // adr: adr/plugin-slot-nested-data.md (writes through setMetadata which deep-merges blogContext)
756
+ let writebackWarning;
757
+ try {
758
+ srv.setMetadata({
759
+ blogContext: {
760
+ lastPublish: {
761
+ publishedAt: new Date().toISOString(),
762
+ ...(liveUrl ? { publishedUrl: liveUrl } : {}),
763
+ ...(shortHash ? { commit: shortHash } : {}),
764
+ file: postRel.replace(/\\/g, '/'),
765
+ },
766
+ },
767
+ });
768
+ // setMetadata doesn't bump docVersion on its own — without an explicit
769
+ // bump, save()→writeToDisk hits the no-op gate (docVersion === lastSavedDocVersion
770
+ // when there's no body change) and the lastPublish writeback never lands on disk.
771
+ // Same convention mcp.ts:1112 uses for active-doc metadata writes.
772
+ srv.bumpDocVersion();
773
+ srv.save();
774
+ }
775
+ catch (err) {
776
+ writebackWarning = `Published successfully, but failed to mark doc as sent: ${err.message}`;
777
+ }
778
+ return {
779
+ success: true,
780
+ file: postRel.replace(/\\/g, '/'),
781
+ commit: shortHash,
782
+ images_committed: noChanges ? 0 : imagesCopied,
783
+ live_url: liveUrl,
784
+ message: noChanges
785
+ ? 'No changes — file already up to date. Doc marked as sent.'
786
+ : `Pushed to ${site.owner}/${site.repo}@${site.branch}`,
787
+ ...(writebackWarning ? { warning: writebackWarning } : {}),
788
+ };
789
+ },
790
+ },
791
+ ];
792
+ }