forkfeed-mcp 1.0.16 → 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.
- package/dist/guide-content.js +78 -189
- package/dist/image-catalog.d.ts +232 -230
- package/dist/image-catalog.js +238 -230
- package/dist/index.js +518 -113
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { join, basename } from 'node:path';
|
|
4
6
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
7
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
8
|
import { z } from 'zod';
|
|
7
9
|
import QRCode from 'qrcode';
|
|
8
10
|
import { GUIDE_CONTENT } from './guide-content.js';
|
|
9
|
-
import { IMAGE_CATALOG } from './image-catalog.js';
|
|
11
|
+
import { IMAGE_CATALOG, resolveImageId } from './image-catalog.js';
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
10
13
|
const APP_SERVER_URL = (process.env.APP_SERVER_URL || 'https://api.forkfeed.link').replace(/\/+$/, '');
|
|
11
14
|
const TOKEN = process.env.FORKFEED_TOKEN || '';
|
|
12
15
|
// ── Manifest schemas ──────────────────────────────────────────────────
|
|
@@ -14,7 +17,7 @@ const forkSchema = z.object({
|
|
|
14
17
|
_id: z.string().min(1, 'Fork _id is required'),
|
|
15
18
|
title: z.string().min(1, 'Fork title is required'),
|
|
16
19
|
description: z.string(),
|
|
17
|
-
imageSrc: z.string(),
|
|
20
|
+
imageSrc: z.string().min(1, 'Fork imageSrc is required'),
|
|
18
21
|
feedIds: z.array(z.string().min(1)).min(1, 'Fork must reference at least one feed'),
|
|
19
22
|
actionLabel: z.string().optional(),
|
|
20
23
|
actionUrl: z.string().optional(),
|
|
@@ -23,7 +26,7 @@ const feedSchema = z.object({
|
|
|
23
26
|
_id: z.string().min(1, 'Feed _id is required'),
|
|
24
27
|
title: z.string().min(1, 'Feed title is required').max(60, 'Feed title max 60 chars'),
|
|
25
28
|
description: z.string().optional(),
|
|
26
|
-
imageSrc: z.string(),
|
|
29
|
+
imageSrc: z.string().min(1, 'Feed imageSrc is required'),
|
|
27
30
|
mode: z.string(),
|
|
28
31
|
scrollDirection: z.string(),
|
|
29
32
|
engagement: z.boolean().optional(),
|
|
@@ -40,14 +43,13 @@ const contentBlockSchema = z.discriminatedUnion('type', [
|
|
|
40
43
|
z.object({ type: z.literal('CONTENT_TITLE'), title: z.string().min(1) }).passthrough(),
|
|
41
44
|
z.object({ type: z.literal('CONTENT_TEXT'), text: z.string().min(1) }).passthrough(),
|
|
42
45
|
z.object({ type: z.literal('CONTENT_CODE'), code: z.string().min(1), language: z.string().optional() }).passthrough(),
|
|
43
|
-
z.object({ type: z.literal('CONTENT_SOCIAL'), name: z.string().min(1), avatarSrc: z.string(), source: socialSourceEnum }).passthrough(),
|
|
46
|
+
z.object({ type: z.literal('CONTENT_SOCIAL'), name: z.string().min(1), subtitle: z.string().optional(), avatarSrc: z.string(), source: socialSourceEnum }).passthrough(),
|
|
44
47
|
z.object({ type: z.literal('CONTENT_SUBTEXT'), text: z.string().min(1) }).passthrough(),
|
|
45
48
|
z.object({ type: z.literal('CONTENT_QUIZ'), question: z.string().min(1), options: z.array(quizOptionSchema).min(2), explanation: z.string().optional() }).passthrough(),
|
|
46
49
|
z.object({ type: z.literal('CONTENT_BUTTON'), label: z.string().min(1), action: buttonActionEnum, target: z.string().min(1) }).passthrough(),
|
|
47
50
|
]);
|
|
48
51
|
const variantSchema = z.discriminatedUnion('type', [
|
|
49
|
-
z.object({ type: z.literal('FULL_IMAGE'), imageSrc: z.string().min(1, 'FULL_IMAGE requires imageSrc'), title: z.string().optional(), subtitle: z.string().optional() }).passthrough(),
|
|
50
|
-
z.object({ type: z.literal('FULL_VIDEO'), videoSrc: z.string().min(1), title: z.string().optional() }).passthrough(),
|
|
52
|
+
z.object({ type: z.literal('FULL_IMAGE'), imageSrc: z.string().min(1, 'FULL_IMAGE requires imageSrc'), title: z.string().optional(), subtitle: z.string().max(200, 'FULL_IMAGE subtitle max 200 chars').optional() }).passthrough(),
|
|
51
53
|
z.object({ type: z.literal('CONTENT'), blocks: z.array(contentBlockSchema).min(1, 'CONTENT variant needs at least one block'), backgroundSrc: z.string().optional() }).passthrough(),
|
|
52
54
|
]);
|
|
53
55
|
const cardSchema = z.object({
|
|
@@ -96,76 +98,334 @@ function authHeaders() {
|
|
|
96
98
|
Authorization: `Bearer ${TOKEN}`,
|
|
97
99
|
};
|
|
98
100
|
}
|
|
99
|
-
// ──
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
'',
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
101
|
+
// ── Git helpers ───────────────────────────────────────────────────────
|
|
102
|
+
async function gitExec(args, cwd) {
|
|
103
|
+
try {
|
|
104
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
105
|
+
cwd,
|
|
106
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
107
|
+
timeout: 15_000,
|
|
108
|
+
});
|
|
109
|
+
return stdout;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
const e = err;
|
|
113
|
+
if (e.code === 'ENOENT')
|
|
114
|
+
throw new Error('git command not found. Ensure git is installed and in PATH.');
|
|
115
|
+
if (e.killed)
|
|
116
|
+
throw new Error('git command timed out after 15s.');
|
|
117
|
+
throw new Error(e.stderr?.trim() || e.message || 'git command failed');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function isGitRepo(cwd) {
|
|
121
|
+
try {
|
|
122
|
+
await gitExec(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function getRepoInfo(cwd) {
|
|
130
|
+
try {
|
|
131
|
+
const url = (await gitExec(['remote', 'get-url', 'origin'], cwd)).trim();
|
|
132
|
+
const m = url.match(/github\.com[/:]([^/]+)\/([^/.]+)/);
|
|
133
|
+
if (m)
|
|
134
|
+
return { owner: m[1], repo: m[2], isLocal: false };
|
|
135
|
+
}
|
|
136
|
+
catch { /* no remote */ }
|
|
137
|
+
return { owner: null, repo: basename(cwd), isLocal: true };
|
|
138
|
+
}
|
|
139
|
+
const SEP = '\x1e'; // ASCII record separator (safe in commit messages)
|
|
140
|
+
async function getCommitList(cwd, count = 15) {
|
|
141
|
+
const fmt = `%H${SEP}%h${SEP}%s${SEP}%an${SEP}%aI`;
|
|
142
|
+
const raw = await gitExec(['log', '--no-merges', `-${count}`, `--format=${fmt}`], cwd);
|
|
143
|
+
return raw.trim().split('\n').filter(Boolean).map((line) => {
|
|
144
|
+
const [sha, shortSha, message, author, date] = line.split(SEP);
|
|
145
|
+
return { sha, shortSha, message, author, date: date?.slice(0, 10) || '' };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function truncateDiff(diff, maxLinesPerFile = 200) {
|
|
149
|
+
const fileDiffs = diff.split(/^(?=diff --git )/m);
|
|
150
|
+
return fileDiffs.map((fd) => {
|
|
151
|
+
if (/^Binary files/.test(fd) || fd.includes('Binary files')) {
|
|
152
|
+
const path = fd.match(/diff --git a\/(.+?) b\//)?.[1] || 'unknown';
|
|
153
|
+
return `Binary file: ${path} (skipped)`;
|
|
154
|
+
}
|
|
155
|
+
const lines = fd.split('\n');
|
|
156
|
+
if (lines.length <= maxLinesPerFile)
|
|
157
|
+
return fd;
|
|
158
|
+
return lines.slice(0, maxLinesPerFile).join('\n') + `\n... [truncated, ${lines.length - maxLinesPerFile} more lines]`;
|
|
159
|
+
}).join('\n');
|
|
160
|
+
}
|
|
161
|
+
async function getCommitDetail(cwd, sha) {
|
|
162
|
+
const [meta, stats, diff] = await Promise.all([
|
|
163
|
+
gitExec(['show', sha, '--format=%H|%h|%s|%an|%aI', '--no-patch'], cwd),
|
|
164
|
+
gitExec(['diff', `${sha}^..${sha}`, '--stat'], cwd),
|
|
165
|
+
gitExec(['diff', `${sha}^..${sha}`, '-U3'], cwd),
|
|
166
|
+
]);
|
|
167
|
+
const [fullSha, shortSha, message, author, date] = meta.trim().split('|');
|
|
168
|
+
return {
|
|
169
|
+
sha: fullSha,
|
|
170
|
+
shortSha,
|
|
171
|
+
message,
|
|
172
|
+
author,
|
|
173
|
+
date: date?.slice(0, 10) || '',
|
|
174
|
+
stats: stats.trim(),
|
|
175
|
+
diff: truncateDiff(diff),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const TAG_KEYWORDS = {
|
|
179
|
+
debug: ['fix', 'bug', 'patch', 'hotfix', 'error', 'crash', 'test', 'spec'],
|
|
180
|
+
deploy: ['deploy', 'release', 'ci', 'cd', 'docker', 'build', 'publish', 'pipeline'],
|
|
181
|
+
disaster: ['break', 'crash', 'fail', 'revert', 'rollback', 'incident', 'down'],
|
|
182
|
+
git: ['merge', 'conflict', 'branch', 'rebase', 'cherry-pick'],
|
|
183
|
+
hype: ['feat', 'feature', 'add', 'new', 'launch', 'ship', 'initial'],
|
|
184
|
+
victory: ['complete', 'finish', 'done', 'success', 'milestone'],
|
|
185
|
+
language: ['typescript', 'javascript', 'python', 'rust', 'go', 'java'],
|
|
186
|
+
general: ['refactor', 'clean', 'update', 'chore', 'docs', 'style'],
|
|
187
|
+
sarcastic: ['hack', 'workaround', 'todo', 'fixme', 'wtf'],
|
|
188
|
+
workplace: ['meeting', 'standup', 'review', 'sprint'],
|
|
189
|
+
lifestyle: ['config', 'setup', 'env', 'tooling'],
|
|
190
|
+
};
|
|
191
|
+
const PATH_TAGS = [
|
|
192
|
+
[/\.(test|spec)\.[jt]sx?$/, 'debug'],
|
|
193
|
+
[/\.github\/workflows|Dockerfile|docker|\.ci|Jenkinsfile/, 'deploy'],
|
|
194
|
+
[/\.env|config/, 'lifestyle'],
|
|
195
|
+
];
|
|
196
|
+
function detectTags(message, filePaths) {
|
|
197
|
+
const tags = new Set();
|
|
198
|
+
const lower = message.toLowerCase();
|
|
199
|
+
for (const [tag, keywords] of Object.entries(TAG_KEYWORDS)) {
|
|
200
|
+
if (keywords.some((kw) => lower.includes(kw)))
|
|
201
|
+
tags.add(tag);
|
|
202
|
+
}
|
|
203
|
+
for (const fp of filePaths) {
|
|
204
|
+
for (const [re, tag] of PATH_TAGS) {
|
|
205
|
+
if (re.test(fp))
|
|
206
|
+
tags.add(tag);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (tags.size === 0)
|
|
210
|
+
tags.add('general');
|
|
211
|
+
return [...tags];
|
|
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
|
+
}
|
|
235
|
+
function filterImagesByTags(tags) {
|
|
236
|
+
const tagSet = new Set(tags);
|
|
237
|
+
function score(entry) {
|
|
238
|
+
return entry.tags.split(',').map((t) => t.trim()).filter((t) => tagSet.has(t)).length;
|
|
239
|
+
}
|
|
240
|
+
const scenes = IMAGE_CATALOG
|
|
241
|
+
.filter((i) => i.id.startsWith('img'))
|
|
242
|
+
.map((i) => ({ ...i, score: score(i) }))
|
|
243
|
+
.sort((a, b) => b.score - a.score)
|
|
244
|
+
.slice(0, 30)
|
|
245
|
+
.map((i) => `${i.id} | ${i.name} | ${i.tags}`);
|
|
246
|
+
const backgrounds = IMAGE_CATALOG
|
|
247
|
+
.filter((i) => i.id.startsWith('bg'))
|
|
248
|
+
.map((i) => ({ ...i, score: score(i) }))
|
|
249
|
+
.sort((a, b) => b.score - a.score)
|
|
250
|
+
.slice(0, 10)
|
|
251
|
+
.map((i) => `${i.id} | ${i.name} | ${i.tags}`);
|
|
252
|
+
return { scenes, backgrounds };
|
|
253
|
+
}
|
|
254
|
+
async function fetchStatusData() {
|
|
255
|
+
const res = await fetch(`${APP_SERVER_URL}/api/content/status`, {
|
|
256
|
+
headers: authHeaders(),
|
|
257
|
+
signal: AbortSignal.timeout(10_000),
|
|
258
|
+
});
|
|
259
|
+
const data = await res.json();
|
|
260
|
+
if (!res.ok)
|
|
261
|
+
throw new Error(`Status check failed (${res.status}): ${data.error || JSON.stringify(data)}`);
|
|
262
|
+
return data;
|
|
263
|
+
}
|
|
264
|
+
// ── Cover titles (fixed per card index) ──────────────────────────────
|
|
265
|
+
const COVERS = [
|
|
266
|
+
{ title: "Explain Like I'm 5", subtitle: "Hopefully now you'll understand what you pushed" },
|
|
267
|
+
{ title: "The Roast", subtitle: "Your code had it coming" },
|
|
268
|
+
{ title: "Commit Message, Decoded", subtitle: "What you wrote vs what you meant vs what actually happened" },
|
|
269
|
+
{ title: "The LinkedIn Post", subtitle: "Mass cringe, freshly generated" },
|
|
270
|
+
{ title: "Statistics", subtitle: "The numbers don't lie, but they do judge" },
|
|
271
|
+
{ title: "Learning Moment", subtitle: "Something useful buried in your chaos" },
|
|
272
|
+
{ title: "Alternatives", subtitle: "What you could have done instead" },
|
|
273
|
+
{ title: "Quiz", subtitle: "Let's see if you even understand your own code" },
|
|
274
|
+
];
|
|
275
|
+
// ── Simplified block inference ───────────────────────────────────────
|
|
276
|
+
function inferBlock(block) {
|
|
277
|
+
if ('question' in block) {
|
|
140
278
|
return {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
},
|
|
146
|
-
],
|
|
147
|
-
isError: true,
|
|
279
|
+
type: 'CONTENT_QUIZ',
|
|
280
|
+
question: block.question,
|
|
281
|
+
options: block.options,
|
|
282
|
+
...(block.explanation != null ? { explanation: block.explanation } : {}),
|
|
148
283
|
};
|
|
149
284
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (!parsed.success) {
|
|
153
|
-
const issues = parsed.error.issues
|
|
154
|
-
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
|
|
155
|
-
.join('\n');
|
|
285
|
+
if ('name' in block && 'avatar' in block) {
|
|
286
|
+
const avatar = block.avatar;
|
|
156
287
|
return {
|
|
157
|
-
|
|
158
|
-
|
|
288
|
+
type: 'CONTENT_SOCIAL',
|
|
289
|
+
name: block.name,
|
|
290
|
+
avatarSrc: avatar.startsWith('http') ? avatar : resolveImageId(avatar),
|
|
291
|
+
source: block.source,
|
|
292
|
+
...(block.subtitle != null ? { subtitle: block.subtitle } : {}),
|
|
159
293
|
};
|
|
160
294
|
}
|
|
161
|
-
|
|
162
|
-
if (crossErr) {
|
|
295
|
+
if ('label' in block && 'action' in block && 'target' in block) {
|
|
163
296
|
return {
|
|
164
|
-
|
|
297
|
+
type: 'CONTENT_BUTTON',
|
|
298
|
+
label: block.label,
|
|
299
|
+
action: block.action,
|
|
300
|
+
target: block.target,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if ('img' in block) {
|
|
304
|
+
return {
|
|
305
|
+
type: 'CONTENT_IMAGE',
|
|
306
|
+
imageSrc: resolveImageId(block.img),
|
|
307
|
+
sizing: (block.sizing || 'wide'),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
if ('code' in block) {
|
|
311
|
+
return {
|
|
312
|
+
type: 'CONTENT_CODE',
|
|
313
|
+
code: block.code,
|
|
314
|
+
...(block.lang != null ? { language: block.lang } : {}),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
if ('subtext' in block) {
|
|
318
|
+
return { type: 'CONTENT_SUBTEXT', text: block.subtext };
|
|
319
|
+
}
|
|
320
|
+
if ('title' in block) {
|
|
321
|
+
return { type: 'CONTENT_TITLE', title: block.title };
|
|
322
|
+
}
|
|
323
|
+
if ('text' in block) {
|
|
324
|
+
return { type: 'CONTENT_TEXT', text: block.text };
|
|
325
|
+
}
|
|
326
|
+
throw new Error(`Cannot infer block type from fields: ${Object.keys(block).join(', ')}`);
|
|
327
|
+
}
|
|
328
|
+
// ── Build input schema ───────────────────────────────────────────────
|
|
329
|
+
const buildInputSchema = z.object({
|
|
330
|
+
sha: z.string().min(1),
|
|
331
|
+
feedTitle: z.string().min(1).max(60),
|
|
332
|
+
feedDescription: z.string().min(1),
|
|
333
|
+
forkTitle: z.string().min(1),
|
|
334
|
+
forkDescription: z.string().min(1),
|
|
335
|
+
cards: z.array(z.object({
|
|
336
|
+
variants: z.array(z.object({
|
|
337
|
+
blocks: z.array(z.record(z.string(), z.unknown())).min(1),
|
|
338
|
+
})).min(1),
|
|
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)'),
|
|
343
|
+
});
|
|
344
|
+
// ── Manifest builder ─────────────────────────────────────────────────
|
|
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
|
+
}
|
|
375
|
+
// Resolve image IDs
|
|
376
|
+
const bgUrls = bgs.map(resolveImageId);
|
|
377
|
+
const coverUrls = input.coverOverride
|
|
378
|
+
? input.coverOverride.map(resolveImageId)
|
|
379
|
+
: bgUrls;
|
|
380
|
+
const actionUrl = repoInfo.owner
|
|
381
|
+
? `https://github.com/${repoInfo.owner}/${repoInfo.repo}`
|
|
382
|
+
: undefined;
|
|
383
|
+
const fork = {
|
|
384
|
+
_id: forkId,
|
|
385
|
+
title: input.forkTitle,
|
|
386
|
+
description: input.forkDescription,
|
|
387
|
+
imageSrc: coverUrls[0],
|
|
388
|
+
feedIds: [...existingFeedIds, feedId],
|
|
389
|
+
...(actionUrl ? { actionLabel: 'View on GitHub', actionUrl } : {}),
|
|
390
|
+
};
|
|
391
|
+
const feed = {
|
|
392
|
+
_id: feedId,
|
|
393
|
+
title: input.feedTitle,
|
|
394
|
+
description: input.feedDescription,
|
|
395
|
+
imageSrc: coverUrls[0],
|
|
396
|
+
mode: 'sequential',
|
|
397
|
+
scrollDirection: 'vertical',
|
|
398
|
+
engagement: true,
|
|
399
|
+
};
|
|
400
|
+
const cards = input.cards.map((card, i) => {
|
|
401
|
+
const coverVariant = {
|
|
402
|
+
type: 'FULL_IMAGE',
|
|
403
|
+
imageSrc: coverUrls[i],
|
|
404
|
+
title: COVERS[i].title,
|
|
405
|
+
subtitle: COVERS[i].subtitle,
|
|
406
|
+
};
|
|
407
|
+
const detailVariants = card.variants.map((v) => ({
|
|
408
|
+
type: 'CONTENT',
|
|
409
|
+
backgroundSrc: bgUrls[i],
|
|
410
|
+
blocks: v.blocks.map((b) => inferBlock(b)),
|
|
411
|
+
}));
|
|
412
|
+
return {
|
|
413
|
+
_id: crypto.randomUUID(),
|
|
414
|
+
feedId,
|
|
415
|
+
order: i,
|
|
416
|
+
variants: [coverVariant, ...detailVariants],
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
return { forks: [fork], feeds: [feed], cards };
|
|
420
|
+
}
|
|
421
|
+
async function pushManifestToServer(manifest) {
|
|
422
|
+
if (!TOKEN) {
|
|
423
|
+
return {
|
|
424
|
+
content: [{ type: 'text', text: 'Error: FORKFEED_TOKEN not set. Get your API token from forkfeed.link/admin/user/token and add it to your .mcp.json env.' }],
|
|
165
425
|
isError: true,
|
|
166
426
|
};
|
|
167
427
|
}
|
|
168
|
-
// Save manifest locally
|
|
428
|
+
// Save manifest locally (best-effort)
|
|
169
429
|
const forkId = manifest.forks?.[0]?._id;
|
|
170
430
|
const filename = `${forkId || `manifest-${Date.now()}`}.json`;
|
|
171
431
|
const dir = join(process.cwd(), 'forkfeed');
|
|
@@ -183,43 +443,31 @@ server.tool('forkfeed_push', 'Push a generated manifest (forks, feeds, cards) to
|
|
|
183
443
|
method: 'POST',
|
|
184
444
|
headers: authHeaders(),
|
|
185
445
|
body: JSON.stringify({ manifest }),
|
|
446
|
+
signal: AbortSignal.timeout(15_000),
|
|
186
447
|
});
|
|
187
448
|
const data = await res.json();
|
|
188
449
|
if (!res.ok) {
|
|
189
450
|
return {
|
|
190
|
-
content: [
|
|
191
|
-
{
|
|
192
|
-
type: 'text',
|
|
193
|
-
text: `Push failed (${res.status}): ${data.error || JSON.stringify(data)}`,
|
|
194
|
-
},
|
|
195
|
-
],
|
|
451
|
+
content: [{ type: 'text', text: `Push failed (${res.status}): ${data.error || JSON.stringify(data)}` }],
|
|
196
452
|
isError: true,
|
|
197
453
|
};
|
|
198
454
|
}
|
|
199
455
|
const forkSummary = data.forks
|
|
200
456
|
?.map((f) => ` - ${f.title} (${f.feeds} feeds)`)
|
|
201
457
|
.join('\n');
|
|
202
|
-
// Generate QR code for the first fork's deep link
|
|
203
458
|
let qrBlock = '';
|
|
204
459
|
if (forkId) {
|
|
205
460
|
const url = `https://forkfeed.link/fork/${forkId}`;
|
|
206
461
|
try {
|
|
207
462
|
const qr = await QRCode.toString(url, { type: 'utf8', errorCorrectionLevel: 'L' });
|
|
208
|
-
qrBlock = [
|
|
209
|
-
'',
|
|
210
|
-
'Scan to open in forkfeed:',
|
|
211
|
-
'',
|
|
212
|
-
qr,
|
|
213
|
-
url,
|
|
214
|
-
].join('\n');
|
|
463
|
+
qrBlock = ['', 'Scan to open in forkfeed:', '', qr, url].join('\n');
|
|
215
464
|
}
|
|
216
465
|
catch {
|
|
217
466
|
qrBlock = `\nLink: ${url}`;
|
|
218
467
|
}
|
|
219
468
|
}
|
|
220
469
|
return {
|
|
221
|
-
content: [
|
|
222
|
-
{
|
|
470
|
+
content: [{
|
|
223
471
|
type: 'text',
|
|
224
472
|
text: [
|
|
225
473
|
'Content pushed successfully!',
|
|
@@ -235,21 +483,192 @@ server.tool('forkfeed_push', 'Push a generated manifest (forks, feeds, cards) to
|
|
|
235
483
|
'To make it public, change visibility in the app (requires admin approval).',
|
|
236
484
|
qrBlock,
|
|
237
485
|
].join('\n'),
|
|
238
|
-
},
|
|
239
|
-
],
|
|
486
|
+
}],
|
|
240
487
|
};
|
|
241
488
|
}
|
|
242
489
|
catch (err) {
|
|
243
490
|
return {
|
|
244
|
-
content: [
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
491
|
+
content: [{ type: 'text', text: `Push failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
492
|
+
isError: true,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// ── MCP Server ─────────────────────────────────────────────────────────
|
|
497
|
+
const server = new McpServer({
|
|
498
|
+
name: 'forkfeed',
|
|
499
|
+
version: '1.0.0',
|
|
500
|
+
});
|
|
501
|
+
// ── Tool: forkfeed_guide ───────────────────────────────────────────────
|
|
502
|
+
server.tool('forkfeed_guide', 'Get the complete guide for generating forkfeed content from GitHub commits. Call this first to learn the format before generating content.', {}, async () => ({
|
|
503
|
+
content: [{ type: 'text', text: GUIDE_CONTENT }],
|
|
504
|
+
}));
|
|
505
|
+
// ── Tool: forkfeed_commits ─────────────────────────────────────────────
|
|
506
|
+
server.tool('forkfeed_commits', 'Read git commits from the current repo. Without sha: lists recent commits with published status. With sha: returns structured commit details + pre-filtered images for content generation.', {
|
|
507
|
+
sha: z.string().optional().describe('Full or short SHA to analyze in detail. Omit to list recent commits.'),
|
|
508
|
+
cwd: z.string().optional().describe('Working directory. Defaults to process.cwd().'),
|
|
509
|
+
}, async ({ sha, cwd: inputCwd }) => {
|
|
510
|
+
const cwd = inputCwd || process.cwd();
|
|
511
|
+
if (!(await isGitRepo(cwd))) {
|
|
512
|
+
return { content: [{ type: 'text', text: 'Not in a git repo. Navigate to a git repository first.' }], isError: true };
|
|
513
|
+
}
|
|
514
|
+
const repoInfo = await getRepoInfo(cwd);
|
|
515
|
+
const repoLabel = repoInfo.owner ? `${repoInfo.owner}/${repoInfo.repo}` : `local/${repoInfo.repo}`;
|
|
516
|
+
// ── List mode ──
|
|
517
|
+
if (!sha) {
|
|
518
|
+
let commits;
|
|
519
|
+
try {
|
|
520
|
+
commits = await getCommitList(cwd);
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
return { content: [{ type: 'text', text: `Failed to read git log: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
524
|
+
}
|
|
525
|
+
if (commits.length === 0) {
|
|
526
|
+
return { content: [{ type: 'text', text: 'No commits found in this repository.' }] };
|
|
527
|
+
}
|
|
528
|
+
// Fetch published status (best-effort)
|
|
529
|
+
let publishedShas = new Set();
|
|
530
|
+
let existingFeedIds = [];
|
|
531
|
+
let statusWarning = '';
|
|
532
|
+
if (TOKEN) {
|
|
533
|
+
try {
|
|
534
|
+
const status = await fetchStatusData();
|
|
535
|
+
existingFeedIds = (status.feeds || []).map((f) => f.externalFeedId);
|
|
536
|
+
for (const feedId of existingFeedIds) {
|
|
537
|
+
const parts = feedId.split('-');
|
|
538
|
+
const feedSha = parts[parts.length - 1];
|
|
539
|
+
if (feedSha)
|
|
540
|
+
publishedShas.add(feedSha);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
statusWarning = '\nNote: could not check published status (network error or token issue).';
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const lines = [
|
|
548
|
+
`Repo: ${repoLabel}`,
|
|
549
|
+
'',
|
|
550
|
+
'| # | SHA | Message | Author | Date | Published |',
|
|
551
|
+
'|---|-----|---------|--------|------|-----------|',
|
|
552
|
+
];
|
|
553
|
+
for (let i = 0; i < commits.length; i++) {
|
|
554
|
+
const c = commits[i];
|
|
555
|
+
const pub = publishedShas.has(c.shortSha) ? 'yes' : '';
|
|
556
|
+
lines.push(`| ${i + 1} | ${c.shortSha} | ${c.message.slice(0, 60)} | ${c.author} | ${c.date} | ${pub} |`);
|
|
557
|
+
}
|
|
558
|
+
if (existingFeedIds.length > 0) {
|
|
559
|
+
lines.push('', `Existing feed IDs (include ALL in fork.feedIds): ${existingFeedIds.join(', ')}`);
|
|
560
|
+
}
|
|
561
|
+
if (statusWarning)
|
|
562
|
+
lines.push(statusWarning);
|
|
563
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
564
|
+
}
|
|
565
|
+
// ── Detail mode ──
|
|
566
|
+
let detail;
|
|
567
|
+
try {
|
|
568
|
+
detail = await getCommitDetail(cwd, sha);
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
return { content: [{ type: 'text', text: `Failed to read commit ${sha}: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
572
|
+
}
|
|
573
|
+
// Extract file paths from stats for tag detection
|
|
574
|
+
const filePathMatches = detail.stats.match(/^\s*(.+?)\s+\|/gm) || [];
|
|
575
|
+
const filePaths = filePathMatches.map((m) => m.trim().replace(/\s+\|$/, ''));
|
|
576
|
+
const tags = detectTags(detail.message, filePaths);
|
|
577
|
+
const images = filterImagesByTags(tags);
|
|
578
|
+
const lines = [
|
|
579
|
+
`## Commit: ${detail.shortSha} - ${detail.message}`,
|
|
580
|
+
`Author: ${detail.author} | Date: ${detail.date}`,
|
|
581
|
+
`Repo: ${repoLabel}`,
|
|
582
|
+
repoInfo.owner ? `GitHub: https://github.com/${repoInfo.owner}/${repoInfo.repo}` : '',
|
|
583
|
+
'',
|
|
584
|
+
'## File Stats',
|
|
585
|
+
detail.stats,
|
|
586
|
+
'',
|
|
587
|
+
'## Diff',
|
|
588
|
+
detail.diff,
|
|
589
|
+
'',
|
|
590
|
+
`## Suggested Images (tags: ${tags.join(', ')})`,
|
|
591
|
+
'',
|
|
592
|
+
'Scene images (for covers + inline):',
|
|
593
|
+
...images.scenes,
|
|
594
|
+
'',
|
|
595
|
+
'Background images (for card backgrounds):',
|
|
596
|
+
...images.backgrounds,
|
|
597
|
+
].filter(Boolean);
|
|
598
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
599
|
+
});
|
|
600
|
+
// ── Tool: forkfeed_push ────────────────────────────────────────────────
|
|
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.', {
|
|
602
|
+
manifest: z
|
|
603
|
+
.object({
|
|
604
|
+
forks: z.array(z.any()),
|
|
605
|
+
feeds: z.array(z.any()),
|
|
606
|
+
cards: z.array(z.any()),
|
|
607
|
+
})
|
|
608
|
+
.describe('The complete manifest with forks, feeds, and cards arrays'),
|
|
609
|
+
}, async ({ manifest }) => {
|
|
610
|
+
// Validate manifest structure before pushing
|
|
611
|
+
const parsed = manifestSchema.safeParse(manifest);
|
|
612
|
+
if (!parsed.success) {
|
|
613
|
+
const issues = parsed.error.issues
|
|
614
|
+
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
|
|
615
|
+
.join('\n');
|
|
616
|
+
return {
|
|
617
|
+
content: [{ type: 'text', text: `Manifest validation failed:\n${issues}\n\nFix the issues above and try again.` }],
|
|
618
|
+
isError: true,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
const crossErr = crossValidate(parsed.data);
|
|
622
|
+
if (crossErr) {
|
|
623
|
+
return {
|
|
624
|
+
content: [{ type: 'text', text: `Manifest cross-reference errors:\n${crossErr}\n\nFix the issues above and try again.` }],
|
|
625
|
+
isError: true,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
return pushManifestToServer(parsed.data);
|
|
629
|
+
});
|
|
630
|
+
// ── Tool: forkfeed_build ──────────────────────────────────────────────
|
|
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'),
|
|
633
|
+
push: z.boolean().optional().describe('Push immediately after building (default: true)'),
|
|
634
|
+
}, async ({ content, push }) => {
|
|
635
|
+
const shouldPush = push !== false;
|
|
636
|
+
// Build the full manifest from simplified input
|
|
637
|
+
let manifest;
|
|
638
|
+
try {
|
|
639
|
+
manifest = await buildManifest(content);
|
|
640
|
+
}
|
|
641
|
+
catch (err) {
|
|
642
|
+
return {
|
|
643
|
+
content: [{ type: 'text', text: `Build failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
644
|
+
isError: true,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
// Validate the assembled manifest
|
|
648
|
+
const parsed = manifestSchema.safeParse(manifest);
|
|
649
|
+
if (!parsed.success) {
|
|
650
|
+
const issues = parsed.error.issues
|
|
651
|
+
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
|
|
652
|
+
.join('\n');
|
|
653
|
+
return {
|
|
654
|
+
content: [{ type: 'text', text: `Built manifest failed validation:\n${issues}` }],
|
|
655
|
+
isError: true,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
const crossErr = crossValidate(parsed.data);
|
|
659
|
+
if (crossErr) {
|
|
660
|
+
return {
|
|
661
|
+
content: [{ type: 'text', text: `Built manifest cross-reference errors:\n${crossErr}` }],
|
|
250
662
|
isError: true,
|
|
251
663
|
};
|
|
252
664
|
}
|
|
665
|
+
if (shouldPush) {
|
|
666
|
+
return pushManifestToServer(parsed.data);
|
|
667
|
+
}
|
|
668
|
+
// Return the manifest for review
|
|
669
|
+
return {
|
|
670
|
+
content: [{ type: 'text', text: `Manifest built successfully (${manifest.cards.length} cards). Review below, then call forkfeed_push to publish.\n\n${JSON.stringify(manifest, null, 2)}` }],
|
|
671
|
+
};
|
|
253
672
|
});
|
|
254
673
|
// ── Tool: forkfeed_status ──────────────────────────────────────────────
|
|
255
674
|
server.tool('forkfeed_status', 'Check your current forkfeed content: which forks and feeds you have published.', {}, async () => {
|
|
@@ -265,27 +684,13 @@ server.tool('forkfeed_status', 'Check your current forkfeed content: which forks
|
|
|
265
684
|
};
|
|
266
685
|
}
|
|
267
686
|
try {
|
|
268
|
-
const
|
|
269
|
-
headers: authHeaders(),
|
|
270
|
-
});
|
|
271
|
-
const data = await res.json();
|
|
272
|
-
if (!res.ok) {
|
|
273
|
-
return {
|
|
274
|
-
content: [
|
|
275
|
-
{
|
|
276
|
-
type: 'text',
|
|
277
|
-
text: `Status check failed (${res.status}): ${data.error || JSON.stringify(data)}`,
|
|
278
|
-
},
|
|
279
|
-
],
|
|
280
|
-
isError: true,
|
|
281
|
-
};
|
|
282
|
-
}
|
|
687
|
+
const data = await fetchStatusData();
|
|
283
688
|
if (!data.forks?.length) {
|
|
284
689
|
return {
|
|
285
690
|
content: [
|
|
286
691
|
{
|
|
287
692
|
type: 'text',
|
|
288
|
-
text: 'No forks published yet. Use forkfeed_guide to learn how to generate content, then push it with
|
|
693
|
+
text: 'No forks published yet. Use forkfeed_guide to learn how to generate content, then push it with forkfeed_build.',
|
|
289
694
|
},
|
|
290
695
|
],
|
|
291
696
|
};
|
|
@@ -327,13 +732,13 @@ server.prompt('forkfeed', 'Turn GitHub commits into swipeable forkfeed content.
|
|
|
327
732
|
type: 'text',
|
|
328
733
|
text: `Turn the commits in this repo into forkfeed content. Follow these steps exactly:
|
|
329
734
|
|
|
330
|
-
1. Call **forkfeed_guide
|
|
331
|
-
2.
|
|
332
|
-
3.
|
|
333
|
-
4. Generate the
|
|
334
|
-
5. Call **
|
|
735
|
+
1. Call **forkfeed_guide** and **forkfeed_commits()** in parallel (no arguments for commits = list mode).
|
|
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.
|
|
739
|
+
5. Call **forkfeed_build** with the simplified content (push defaults to true).
|
|
335
740
|
|
|
336
|
-
Start now
|
|
741
|
+
Start now.`,
|
|
337
742
|
},
|
|
338
743
|
},
|
|
339
744
|
],
|