forkfeed-mcp 1.2.0 → 1.3.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.
@@ -14,7 +14,7 @@ Turn GitHub commits into swipeable card content. One fork per repo, one feed per
14
14
 
15
15
  1. Call **forkfeed_guide** and **forkfeed_commits()** in parallel
16
16
  2. Show commits table, ask which ONE to process
17
- 3. Call **forkfeed_commits(sha)** to get diff, stats, and pre-filtered images
17
+ 3. Call **forkfeed_commits(sha)** to get diff, stats, and suggested scene images
18
18
  4. Generate simplified content JSON (see format below)
19
19
  5. Call **forkfeed_build** with the content (push defaults to true)
20
20
 
@@ -22,26 +22,19 @@ Turn GitHub commits into swipeable card content. One fork per repo, one feed per
22
22
 
23
23
  ## Simplified content format (for forkfeed_build)
24
24
 
25
- The builder handles UUIDs, full image URLs, cover variants, type wrappers, feedId/order, and fork/feed boilerplate. You only provide creative content.
25
+ The builder auto-detects repo info (owner, repo, GitHub URL), auto-assigns card backgrounds, and fetches existing feed IDs from the server. You only provide creative content.
26
26
 
27
27
  \`\`\`json
28
28
  {
29
- "owner": "owner-or-local",
30
- "repo": "repo-name",
31
- "sha": "7char",
29
+ "sha": "7char-sha",
32
30
  "feedTitle": "Max 60 chars headline",
33
31
  "feedDescription": "Mon DD: one-line impact summary",
34
32
  "forkTitle": "Project Name",
35
33
  "forkDescription": "What the project IS",
36
- "actionUrl": "https://github.com/owner/repo",
37
- "existingFeedIds": [],
38
- "images": {
39
- "bg": ["bg10", "bg11", "bg20", "bg25", "bg9", "bg12", "bg30", "bg18"]
40
- },
41
34
  "cards": [
42
35
  {
43
36
  "variants": [
44
- { "blocks": [{ "img": "img47", "sizing": "wide" }, { "title": "Section heading" }, { "text": "Paragraph content..." }] },
37
+ { "blocks": [{ "img": "img47" }, { "title": "Section heading" }, { "text": "Paragraph content..." }] },
45
38
  { "blocks": [{ "title": "Another section" }, { "text": "More content..." }] }
46
39
  ]
47
40
  }
@@ -49,6 +42,8 @@ The builder handles UUIDs, full image URLs, cover variants, type wrappers, feedI
49
42
  }
50
43
  \`\`\`
51
44
 
45
+ Only 5 fields + cards. Everything else is auto-populated.
46
+
52
47
  ### Block types (inferred from fields)
53
48
 
54
49
  | Write this | Becomes | Notes |
@@ -59,19 +54,27 @@ The builder handles UUIDs, full image URLs, cover variants, type wrappers, feedI
59
54
  | \`{ "text": "Paragraph..." }\` | CONTENT_TEXT | 50-200 words, use \\\\n\\\\n for breaks |
60
55
  | \`{ "code": "const x = 1", "lang": "typescript" }\` | CONTENT_CODE | Real code from diff, 5-20 lines, strip +/- prefixes |
61
56
  | \`{ "subtext": "Pro tip..." }\` | CONTENT_SUBTEXT | Aside or protip |
62
- | \`{ "name": "Chad", "subtitle": "10x Engineer", "avatar": "img98", "source": "linkedin" }\` | CONTENT_SOCIAL | Cards 2-3 only. source: x, linkedin, threads, etc. |
57
+ | \`{ "name": "Chad", "subtitle": "10x Engineer", "avatar": "img98", "source": "linkedin" }\` | CONTENT_SOCIAL | Cards 2-3 only. avatar can be image ID or URL. source: x, linkedin, threads, etc. |
63
58
  | \`{ "question": "What does X do?", "options": [{"label": "A", "correct": false}, {"label": "B", "correct": true}], "explanation": "Because..." }\` | CONTENT_QUIZ | Card 7 only. 4 options recommended, min 2, one correct, always include explanation |
64
59
  | \`{ "label": "Google it", "action": "url", "target": "https://google.com/search?q=topic" }\` | CONTENT_BUTTON | Card 5 only, MUST be last block in every detail variant |
65
60
 
66
61
  ### What the builder does automatically
62
+ - Detects repo owner/name from git remote
63
+ - Generates fork/feed IDs from convention (tfip-{owner}-{repo}-{sha})
64
+ - Constructs GitHub action URL
65
+ - Fetches existing feed IDs from server (for incremental updates)
66
+ - Assigns 8 unique card backgrounds from preference table + commit tags
67
67
  - Generates UUID v4 for each card ID
68
68
  - Resolves short image IDs (img47, bg10) to full CDN URLs
69
69
  - Adds FULL_IMAGE cover variant with fixed title/subtitle per card type
70
- - Sets backgroundSrc on all CONTENT variants from images.bg[cardIndex]
71
- - Sets feedId and order on each card
72
- - Builds fork and feed objects with correct IDs and metadata
70
+ - Sets backgroundSrc, feedId, and order on each card
73
71
  - Validates the assembled manifest before pushing
74
72
 
73
+ ### Optional overrides
74
+ - \`bgOverride\`: array of 8 background IDs to override auto-assignment
75
+ - \`coverOverride\`: array of 8 cover image IDs (defaults to backgrounds)
76
+ - \`cwd\`: working directory if not process.cwd()
77
+
75
78
  ---
76
79
 
77
80
  ## The 8 cards
@@ -104,7 +107,7 @@ Each card = array of detail variants (cover is auto-generated). Provide 1+ varia
104
107
  ## Key rules
105
108
 
106
109
  - Exactly 8 cards in the cards array (one per section above)
107
- - images.bg must have 8 unique background IDs
110
+ - Use short image IDs from forkfeed_commits output (img47, not full URLs)
108
111
  - 12-20 unique scene images (img*) across the feed, no duplicate within same card
109
112
  - Cards 0-5: code must be REAL from the diff (never fabricated)
110
113
  - Card 6: synthesized code IS allowed
@@ -115,32 +118,13 @@ Each card = array of detail variants (cover is auto-generated). Provide 1+ varia
115
118
  - No em dashes, smart quotes, or non-ASCII characters
116
119
  - Feed title max 60 chars
117
120
 
118
- ### Images
119
-
120
- Images are pre-filtered by **forkfeed_commits(sha)**. Pick from the suggested list.
121
-
122
- **BG preferences:** ELI5: bg10/bg27 | Roast: bg11/bg1 | Decoded: bg20/bg11 | LinkedIn: bg25/bg24 | Stats: bg9/bg3 | Learning: match tech | Alts: bg25/bg30 | Quiz: bg18/bg5
123
-
124
121
  ### Tone
125
122
  Casual, cheeky, technically accurate. Use "you," contractions, short paragraphs, occasional swearing. Humor is the default.
126
123
 
127
124
  ### Incremental updates
128
- **forkfeed_commits()** (list mode) shows which commits have published feeds and lists existing feed IDs.
125
+ **forkfeed_commits()** (list mode) shows which commits have published feeds. The builder auto-fetches existing feed IDs, so you don't need to track them.
129
126
 
130
- - **New commit**: Include ALL existing feed IDs in existingFeedIds. Missing old IDs causes deletion.
127
+ - **New commit**: Just generate content. The builder handles feed ID preservation.
131
128
  - **Existing commit**: Warn the user. Only regenerate if they confirm.
132
129
  - **One commit per run**: Never process multiple commits at once.
133
-
134
- ---
135
-
136
- ## Advanced: direct push with full manifest (forkfeed_push)
137
-
138
- If you need full control, you can build the manifest manually and use forkfeed_push directly. The full manifest requires:
139
- - Fork/feed objects with all fields (mode, scrollDirection, engagement, etc.)
140
- - FULL_IMAGE cover variant[0] per card with fixed titles/subtitles
141
- - CONTENT variants with type field, full image URLs, backgroundSrc
142
- - UUID v4 card IDs, feedId and order on each card
143
-
144
- ID conventions: Fork: \`tfip-{owner}-{repo}\`, Feed: \`tfip-{owner}-{repo}-{7char-sha}\`, Card: UUID v4
145
- All IDs must match \`/^[a-z0-9-]+$/\` (except card UUIDs).
146
130
  `.trim();
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
8
8
  import { z } from 'zod';
9
9
  import QRCode from 'qrcode';
10
10
  import { GUIDE_CONTENT } from './guide-content.js';
11
- import { IMAGE_CATALOG, IMAGE_BASE_URL, resolveImageId } from './image-catalog.js';
11
+ import { IMAGE_CATALOG, resolveImageId } from './image-catalog.js';
12
12
  const execFileAsync = promisify(execFile);
13
13
  const APP_SERVER_URL = (process.env.APP_SERVER_URL || 'https://api.forkfeed.link').replace(/\/+$/, '');
14
14
  const TOKEN = process.env.FORKFEED_TOKEN || '';
@@ -136,11 +136,12 @@ async function getRepoInfo(cwd) {
136
136
  catch { /* no remote */ }
137
137
  return { owner: null, repo: basename(cwd), isLocal: true };
138
138
  }
139
+ const SEP = '\x1e'; // ASCII record separator (safe in commit messages)
139
140
  async function getCommitList(cwd, count = 15) {
140
- const fmt = '%H|%h|%s|%an|%aI|%P';
141
+ const fmt = `%H${SEP}%h${SEP}%s${SEP}%an${SEP}%aI`;
141
142
  const raw = await gitExec(['log', '--no-merges', `-${count}`, `--format=${fmt}`], cwd);
142
143
  return raw.trim().split('\n').filter(Boolean).map((line) => {
143
- const [sha, shortSha, message, author, date] = line.split('|');
144
+ const [sha, shortSha, message, author, date] = line.split(SEP);
144
145
  return { sha, shortSha, message, author, date: date?.slice(0, 10) || '' };
145
146
  });
146
147
  }
@@ -209,6 +210,28 @@ function detectTags(message, filePaths) {
209
210
  tags.add('general');
210
211
  return [...tags];
211
212
  }
213
+ function parseFilePathsFromStats(stats) {
214
+ const matches = stats.match(/^\s*(.+?)\s+\|/gm) || [];
215
+ return matches.map((m) => m.trim().replace(/\s+\|$/, ''));
216
+ }
217
+ const BG_PREFS = [
218
+ ['bg10', 'bg27'], // 0: ELI5
219
+ ['bg11', 'bg1'], // 1: Roast
220
+ ['bg20', 'bg11'], // 2: Decoded
221
+ ['bg25', 'bg24'], // 3: LinkedIn
222
+ ['bg9', 'bg3'], // 4: Stats
223
+ ['bg12', 'bg13', 'bg14', 'bg15', 'bg17'], // 5: Learning (tech-match)
224
+ ['bg25', 'bg30'], // 6: Alternatives
225
+ ['bg18', 'bg5'], // 7: Quiz
226
+ ];
227
+ function assignBackgrounds(_tags) {
228
+ const used = new Set();
229
+ return BG_PREFS.map((prefs) => {
230
+ const pick = prefs.find((bg) => !used.has(bg)) || prefs[0];
231
+ used.add(pick);
232
+ return pick;
233
+ });
234
+ }
212
235
  function filterImagesByTags(tags) {
213
236
  const tagSet = new Set(tags);
214
237
  function score(entry) {
@@ -219,13 +242,13 @@ function filterImagesByTags(tags) {
219
242
  .map((i) => ({ ...i, score: score(i) }))
220
243
  .sort((a, b) => b.score - a.score)
221
244
  .slice(0, 30)
222
- .map((i) => `${i.id} | ${i.name} | ${i.tags} | ${IMAGE_BASE_URL}${i.file}`);
245
+ .map((i) => `${i.id} | ${i.name} | ${i.tags}`);
223
246
  const backgrounds = IMAGE_CATALOG
224
247
  .filter((i) => i.id.startsWith('bg'))
225
248
  .map((i) => ({ ...i, score: score(i) }))
226
249
  .sort((a, b) => b.score - a.score)
227
250
  .slice(0, 10)
228
- .map((i) => `${i.id} | ${i.name} | ${i.tags} | ${IMAGE_BASE_URL}${i.file}`);
251
+ .map((i) => `${i.id} | ${i.name} | ${i.tags}`);
229
252
  return { scenes, backgrounds };
230
253
  }
231
254
  async function fetchStatusData() {
@@ -260,10 +283,11 @@ function inferBlock(block) {
260
283
  };
261
284
  }
262
285
  if ('name' in block && 'avatar' in block) {
286
+ const avatar = block.avatar;
263
287
  return {
264
288
  type: 'CONTENT_SOCIAL',
265
289
  name: block.name,
266
- avatarSrc: resolveImageId(block.avatar),
290
+ avatarSrc: avatar.startsWith('http') ? avatar : resolveImageId(avatar),
267
291
  source: block.source,
268
292
  ...(block.subtitle != null ? { subtitle: block.subtitle } : {}),
269
293
  };
@@ -303,41 +327,66 @@ function inferBlock(block) {
303
327
  }
304
328
  // ── Build input schema ───────────────────────────────────────────────
305
329
  const buildInputSchema = z.object({
306
- owner: z.string().min(1),
307
- repo: z.string().min(1),
308
330
  sha: z.string().min(1),
309
331
  feedTitle: z.string().min(1).max(60),
310
332
  feedDescription: z.string().min(1),
311
333
  forkTitle: z.string().min(1),
312
334
  forkDescription: z.string().min(1),
313
- actionUrl: z.string().optional(),
314
- existingFeedIds: z.array(z.string()).optional(),
315
- images: z.object({
316
- bg: z.array(z.string()).length(8),
317
- cover: z.array(z.string()).length(8).optional(),
318
- }),
319
335
  cards: z.array(z.object({
320
336
  variants: z.array(z.object({
321
337
  blocks: z.array(z.record(z.string(), z.unknown())).min(1),
322
338
  })).min(1),
323
339
  })).length(8),
340
+ cwd: z.string().optional().describe('Working directory (defaults to process.cwd())'),
341
+ bgOverride: z.array(z.string()).length(8).optional().describe('Override auto-assigned background IDs'),
342
+ coverOverride: z.array(z.string()).length(8).optional().describe('Override cover image IDs (defaults to backgrounds)'),
324
343
  });
325
344
  // ── Manifest builder ─────────────────────────────────────────────────
326
- function buildManifest(input) {
327
- const forkId = `tfip-${input.owner}-${input.repo}`;
328
- const feedId = `tfip-${input.owner}-${input.repo}-${input.sha.slice(0, 7)}`;
345
+ async function buildManifest(input) {
346
+ const cwd = input.cwd || process.cwd();
347
+ // Auto-detect repo info
348
+ const repoInfo = await getRepoInfo(cwd);
349
+ const owner = repoInfo.owner || 'local';
350
+ const repo = repoInfo.repo;
351
+ const sha7 = input.sha.slice(0, 7);
352
+ const forkId = `tfip-${owner}-${repo}`;
353
+ const feedId = `tfip-${owner}-${repo}-${sha7}`;
354
+ // Auto-assign backgrounds (or use override)
355
+ let bgs;
356
+ if (input.bgOverride) {
357
+ bgs = input.bgOverride;
358
+ }
359
+ else {
360
+ const meta = await gitExec(['show', input.sha, '--format=%s', '--no-patch'], cwd);
361
+ const statOutput = await gitExec(['diff', `${input.sha}^..${input.sha}`, '--stat'], cwd);
362
+ const filePaths = parseFilePathsFromStats(statOutput);
363
+ const tags = detectTags(meta.trim(), filePaths);
364
+ bgs = assignBackgrounds(tags);
365
+ }
366
+ // Auto-fetch existing feed IDs (best-effort)
367
+ let existingFeedIds = [];
368
+ if (TOKEN) {
369
+ try {
370
+ const status = await fetchStatusData();
371
+ existingFeedIds = (status.feeds || []).map((f) => f.externalFeedId);
372
+ }
373
+ catch { /* best-effort */ }
374
+ }
329
375
  // Resolve image IDs
330
- const bgUrls = input.images.bg.map(resolveImageId);
331
- const coverUrls = input.images.cover
332
- ? input.images.cover.map(resolveImageId)
376
+ const bgUrls = bgs.map(resolveImageId);
377
+ const coverUrls = input.coverOverride
378
+ ? input.coverOverride.map(resolveImageId)
333
379
  : bgUrls;
380
+ const actionUrl = repoInfo.owner
381
+ ? `https://github.com/${repoInfo.owner}/${repoInfo.repo}`
382
+ : undefined;
334
383
  const fork = {
335
384
  _id: forkId,
336
385
  title: input.forkTitle,
337
386
  description: input.forkDescription,
338
387
  imageSrc: coverUrls[0],
339
- feedIds: [...(input.existingFeedIds || []), feedId],
340
- ...(input.actionUrl ? { actionLabel: 'View on GitHub', actionUrl: input.actionUrl } : {}),
388
+ feedIds: [...existingFeedIds, feedId],
389
+ ...(actionUrl ? { actionLabel: 'View on GitHub', actionUrl } : {}),
341
390
  };
342
391
  const feed = {
343
392
  _id: feedId,
@@ -548,30 +597,6 @@ server.tool('forkfeed_commits', 'Read git commits from the current repo. Without
548
597
  ].filter(Boolean);
549
598
  return { content: [{ type: 'text', text: lines.join('\n') }] };
550
599
  });
551
- // ── Tool: forkfeed_images ──────────────────────────────────────────────
552
- server.tool('forkfeed_images', 'Get the IT Scenes image catalog (200 scene images + 30 backgrounds). Use this to pick images that match your content by tags and name. Call after forkfeed_guide.', {}, async () => {
553
- const scenes = IMAGE_CATALOG.filter((i) => i.id.startsWith('img'));
554
- const bgs = IMAGE_CATALOG.filter((i) => i.id.startsWith('bg'));
555
- const lines = [
556
- '# IT Scenes Image Catalog',
557
- '',
558
- `Base URL: ${IMAGE_BASE_URL}`,
559
- 'Prepend base URL to all filenames below to get the full image URL.',
560
- '',
561
- 'Match images to content by tags and name. Use img* for covers and inline images, bg* for card backgrounds.',
562
- '',
563
- 'Tags: deploy, git, disaster, debug, hype, victory, beginner, language, lifestyle, workplace, sarcastic, general',
564
- '',
565
- '## Scene Images (covers + inline)',
566
- '',
567
- ...scenes.map((i) => `${i.id} | ${i.name} | ${i.tags} | ${i.file}`),
568
- '',
569
- '## Background Images',
570
- '',
571
- ...bgs.map((i) => `${i.id} | ${i.name} | ${i.tags} | ${i.file}`),
572
- ];
573
- return { content: [{ type: 'text', text: lines.join('\n') }] };
574
- });
575
600
  // ── Tool: forkfeed_push ────────────────────────────────────────────────
576
601
  server.tool('forkfeed_push', 'Push a generated manifest (forks, feeds, cards) to forkfeed. The manifest JSON must conform to the structure described in forkfeed_guide.', {
577
602
  manifest: z
@@ -603,15 +628,15 @@ server.tool('forkfeed_push', 'Push a generated manifest (forks, feeds, cards) to
603
628
  return pushManifestToServer(parsed.data);
604
629
  });
605
630
  // ── Tool: forkfeed_build ──────────────────────────────────────────────
606
- server.tool('forkfeed_build', 'Build a forkfeed manifest from simplified content and push it. Generates UUIDs, resolves image IDs to URLs, adds cover variants automatically. Use this instead of forkfeed_push for faster content creation.', {
607
- content: buildInputSchema.describe('Simplified content: repo info, image IDs, and creative card content only'),
631
+ server.tool('forkfeed_build', 'Build a forkfeed manifest from simplified content and push it. Auto-detects repo info, assigns backgrounds, fetches existing feeds. Use this instead of forkfeed_push.', {
632
+ content: buildInputSchema.describe('Simplified content: sha, titles, descriptions, and 8 cards with blocks'),
608
633
  push: z.boolean().optional().describe('Push immediately after building (default: true)'),
609
634
  }, async ({ content, push }) => {
610
635
  const shouldPush = push !== false;
611
636
  // Build the full manifest from simplified input
612
637
  let manifest;
613
638
  try {
614
- manifest = buildManifest(content);
639
+ manifest = await buildManifest(content);
615
640
  }
616
641
  catch (err) {
617
642
  return {
@@ -665,7 +690,7 @@ server.tool('forkfeed_status', 'Check your current forkfeed content: which forks
665
690
  content: [
666
691
  {
667
692
  type: 'text',
668
- text: 'No forks published yet. Use forkfeed_guide to learn how to generate content, then push it with forkfeed_push.',
693
+ text: 'No forks published yet. Use forkfeed_guide to learn how to generate content, then push it with forkfeed_build.',
669
694
  },
670
695
  ],
671
696
  };
@@ -708,9 +733,9 @@ server.prompt('forkfeed', 'Turn GitHub commits into swipeable forkfeed content.
708
733
  text: `Turn the commits in this repo into forkfeed content. Follow these steps exactly:
709
734
 
710
735
  1. Call **forkfeed_guide** and **forkfeed_commits()** in parallel (no arguments for commits = list mode).
711
- 2. Show the commits table from forkfeed_commits. It already indicates which commits have published feeds and lists existing feed IDs. Ask which ONE commit to process. One commit at a time, never more. Do NOT ask about image style.
712
- 3. Call **forkfeed_commits** with the selected commit SHA. This returns the diff, file stats, and pre-filtered images. Do NOT call forkfeed_images separately, do NOT run git commands yourself.
713
- 4. Generate the simplified content JSON as documented in the guide. Use short image IDs (img47, bg10), not full URLs. Do NOT generate UUIDs, cover variants, type wrappers, or fork/feed boilerplate. The builder handles all of that. Include ALL existing feed IDs (listed in step 1 output) in existingFeedIds.
736
+ 2. Show the commits table from forkfeed_commits. It already indicates which commits have published feeds. Ask which ONE commit to process. One commit at a time, never more. Do NOT ask about image style.
737
+ 3. Call **forkfeed_commits** with the selected commit SHA. This returns the diff, file stats, and suggested scene images. Do NOT run git commands yourself.
738
+ 4. Generate the simplified content JSON: sha, feedTitle, feedDescription, forkTitle, forkDescription, and 8 cards with blocks. Use short image IDs (img47) for inline images. Do NOT provide owner, repo, backgrounds, existingFeedIds, UUIDs, covers, or type wrappers. The builder auto-detects and generates all of that.
714
739
  5. Call **forkfeed_build** with the simplified content (push defaults to true).
715
740
 
716
741
  Start now.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forkfeed-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "MCP server for pushing GitHub commits to forkfeed",
5
5
  "type": "module",
6
6
  "bin": {