forkfeed-mcp 1.0.15 → 1.2.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 +86 -220
- package/dist/image-catalog.d.ts +232 -230
- package/dist/image-catalog.js +238 -230
- package/dist/index.js +492 -125
- 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, IMAGE_BASE_URL, 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,285 @@ 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
|
+
async function getCommitList(cwd, count = 15) {
|
|
140
|
+
const fmt = '%H|%h|%s|%an|%aI|%P';
|
|
141
|
+
const raw = await gitExec(['log', '--no-merges', `-${count}`, `--format=${fmt}`], cwd);
|
|
142
|
+
return raw.trim().split('\n').filter(Boolean).map((line) => {
|
|
143
|
+
const [sha, shortSha, message, author, date] = line.split('|');
|
|
144
|
+
return { sha, shortSha, message, author, date: date?.slice(0, 10) || '' };
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
function truncateDiff(diff, maxLinesPerFile = 200) {
|
|
148
|
+
const fileDiffs = diff.split(/^(?=diff --git )/m);
|
|
149
|
+
return fileDiffs.map((fd) => {
|
|
150
|
+
if (/^Binary files/.test(fd) || fd.includes('Binary files')) {
|
|
151
|
+
const path = fd.match(/diff --git a\/(.+?) b\//)?.[1] || 'unknown';
|
|
152
|
+
return `Binary file: ${path} (skipped)`;
|
|
153
|
+
}
|
|
154
|
+
const lines = fd.split('\n');
|
|
155
|
+
if (lines.length <= maxLinesPerFile)
|
|
156
|
+
return fd;
|
|
157
|
+
return lines.slice(0, maxLinesPerFile).join('\n') + `\n... [truncated, ${lines.length - maxLinesPerFile} more lines]`;
|
|
158
|
+
}).join('\n');
|
|
159
|
+
}
|
|
160
|
+
async function getCommitDetail(cwd, sha) {
|
|
161
|
+
const [meta, stats, diff] = await Promise.all([
|
|
162
|
+
gitExec(['show', sha, '--format=%H|%h|%s|%an|%aI', '--no-patch'], cwd),
|
|
163
|
+
gitExec(['diff', `${sha}^..${sha}`, '--stat'], cwd),
|
|
164
|
+
gitExec(['diff', `${sha}^..${sha}`, '-U3'], cwd),
|
|
165
|
+
]);
|
|
166
|
+
const [fullSha, shortSha, message, author, date] = meta.trim().split('|');
|
|
167
|
+
return {
|
|
168
|
+
sha: fullSha,
|
|
169
|
+
shortSha,
|
|
170
|
+
message,
|
|
171
|
+
author,
|
|
172
|
+
date: date?.slice(0, 10) || '',
|
|
173
|
+
stats: stats.trim(),
|
|
174
|
+
diff: truncateDiff(diff),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const TAG_KEYWORDS = {
|
|
178
|
+
debug: ['fix', 'bug', 'patch', 'hotfix', 'error', 'crash', 'test', 'spec'],
|
|
179
|
+
deploy: ['deploy', 'release', 'ci', 'cd', 'docker', 'build', 'publish', 'pipeline'],
|
|
180
|
+
disaster: ['break', 'crash', 'fail', 'revert', 'rollback', 'incident', 'down'],
|
|
181
|
+
git: ['merge', 'conflict', 'branch', 'rebase', 'cherry-pick'],
|
|
182
|
+
hype: ['feat', 'feature', 'add', 'new', 'launch', 'ship', 'initial'],
|
|
183
|
+
victory: ['complete', 'finish', 'done', 'success', 'milestone'],
|
|
184
|
+
language: ['typescript', 'javascript', 'python', 'rust', 'go', 'java'],
|
|
185
|
+
general: ['refactor', 'clean', 'update', 'chore', 'docs', 'style'],
|
|
186
|
+
sarcastic: ['hack', 'workaround', 'todo', 'fixme', 'wtf'],
|
|
187
|
+
workplace: ['meeting', 'standup', 'review', 'sprint'],
|
|
188
|
+
lifestyle: ['config', 'setup', 'env', 'tooling'],
|
|
189
|
+
};
|
|
190
|
+
const PATH_TAGS = [
|
|
191
|
+
[/\.(test|spec)\.[jt]sx?$/, 'debug'],
|
|
192
|
+
[/\.github\/workflows|Dockerfile|docker|\.ci|Jenkinsfile/, 'deploy'],
|
|
193
|
+
[/\.env|config/, 'lifestyle'],
|
|
194
|
+
];
|
|
195
|
+
function detectTags(message, filePaths) {
|
|
196
|
+
const tags = new Set();
|
|
197
|
+
const lower = message.toLowerCase();
|
|
198
|
+
for (const [tag, keywords] of Object.entries(TAG_KEYWORDS)) {
|
|
199
|
+
if (keywords.some((kw) => lower.includes(kw)))
|
|
200
|
+
tags.add(tag);
|
|
201
|
+
}
|
|
202
|
+
for (const fp of filePaths) {
|
|
203
|
+
for (const [re, tag] of PATH_TAGS) {
|
|
204
|
+
if (re.test(fp))
|
|
205
|
+
tags.add(tag);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (tags.size === 0)
|
|
209
|
+
tags.add('general');
|
|
210
|
+
return [...tags];
|
|
211
|
+
}
|
|
212
|
+
function filterImagesByTags(tags) {
|
|
213
|
+
const tagSet = new Set(tags);
|
|
214
|
+
function score(entry) {
|
|
215
|
+
return entry.tags.split(',').map((t) => t.trim()).filter((t) => tagSet.has(t)).length;
|
|
216
|
+
}
|
|
217
|
+
const scenes = IMAGE_CATALOG
|
|
218
|
+
.filter((i) => i.id.startsWith('img'))
|
|
219
|
+
.map((i) => ({ ...i, score: score(i) }))
|
|
220
|
+
.sort((a, b) => b.score - a.score)
|
|
221
|
+
.slice(0, 30)
|
|
222
|
+
.map((i) => `${i.id} | ${i.name} | ${i.tags} | ${IMAGE_BASE_URL}${i.file}`);
|
|
223
|
+
const backgrounds = IMAGE_CATALOG
|
|
224
|
+
.filter((i) => i.id.startsWith('bg'))
|
|
225
|
+
.map((i) => ({ ...i, score: score(i) }))
|
|
226
|
+
.sort((a, b) => b.score - a.score)
|
|
227
|
+
.slice(0, 10)
|
|
228
|
+
.map((i) => `${i.id} | ${i.name} | ${i.tags} | ${IMAGE_BASE_URL}${i.file}`);
|
|
229
|
+
return { scenes, backgrounds };
|
|
230
|
+
}
|
|
231
|
+
async function fetchStatusData() {
|
|
232
|
+
const res = await fetch(`${APP_SERVER_URL}/api/content/status`, {
|
|
233
|
+
headers: authHeaders(),
|
|
234
|
+
signal: AbortSignal.timeout(10_000),
|
|
235
|
+
});
|
|
236
|
+
const data = await res.json();
|
|
237
|
+
if (!res.ok)
|
|
238
|
+
throw new Error(`Status check failed (${res.status}): ${data.error || JSON.stringify(data)}`);
|
|
239
|
+
return data;
|
|
240
|
+
}
|
|
241
|
+
// ── Cover titles (fixed per card index) ──────────────────────────────
|
|
242
|
+
const COVERS = [
|
|
243
|
+
{ title: "Explain Like I'm 5", subtitle: "Hopefully now you'll understand what you pushed" },
|
|
244
|
+
{ title: "The Roast", subtitle: "Your code had it coming" },
|
|
245
|
+
{ title: "Commit Message, Decoded", subtitle: "What you wrote vs what you meant vs what actually happened" },
|
|
246
|
+
{ title: "The LinkedIn Post", subtitle: "Mass cringe, freshly generated" },
|
|
247
|
+
{ title: "Statistics", subtitle: "The numbers don't lie, but they do judge" },
|
|
248
|
+
{ title: "Learning Moment", subtitle: "Something useful buried in your chaos" },
|
|
249
|
+
{ title: "Alternatives", subtitle: "What you could have done instead" },
|
|
250
|
+
{ title: "Quiz", subtitle: "Let's see if you even understand your own code" },
|
|
251
|
+
];
|
|
252
|
+
// ── Simplified block inference ───────────────────────────────────────
|
|
253
|
+
function inferBlock(block) {
|
|
254
|
+
if ('question' in block) {
|
|
140
255
|
return {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
},
|
|
146
|
-
],
|
|
147
|
-
isError: true,
|
|
256
|
+
type: 'CONTENT_QUIZ',
|
|
257
|
+
question: block.question,
|
|
258
|
+
options: block.options,
|
|
259
|
+
...(block.explanation != null ? { explanation: block.explanation } : {}),
|
|
148
260
|
};
|
|
149
261
|
}
|
|
150
|
-
|
|
151
|
-
const parsed = manifestSchema.safeParse(manifest);
|
|
152
|
-
if (!parsed.success) {
|
|
153
|
-
const issues = parsed.error.issues
|
|
154
|
-
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
|
|
155
|
-
.join('\n');
|
|
262
|
+
if ('name' in block && 'avatar' in block) {
|
|
156
263
|
return {
|
|
157
|
-
|
|
158
|
-
|
|
264
|
+
type: 'CONTENT_SOCIAL',
|
|
265
|
+
name: block.name,
|
|
266
|
+
avatarSrc: resolveImageId(block.avatar),
|
|
267
|
+
source: block.source,
|
|
268
|
+
...(block.subtitle != null ? { subtitle: block.subtitle } : {}),
|
|
159
269
|
};
|
|
160
270
|
}
|
|
161
|
-
|
|
162
|
-
if (crossErr) {
|
|
271
|
+
if ('label' in block && 'action' in block && 'target' in block) {
|
|
163
272
|
return {
|
|
164
|
-
|
|
273
|
+
type: 'CONTENT_BUTTON',
|
|
274
|
+
label: block.label,
|
|
275
|
+
action: block.action,
|
|
276
|
+
target: block.target,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
if ('img' in block) {
|
|
280
|
+
return {
|
|
281
|
+
type: 'CONTENT_IMAGE',
|
|
282
|
+
imageSrc: resolveImageId(block.img),
|
|
283
|
+
sizing: (block.sizing || 'wide'),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
if ('code' in block) {
|
|
287
|
+
return {
|
|
288
|
+
type: 'CONTENT_CODE',
|
|
289
|
+
code: block.code,
|
|
290
|
+
...(block.lang != null ? { language: block.lang } : {}),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if ('subtext' in block) {
|
|
294
|
+
return { type: 'CONTENT_SUBTEXT', text: block.subtext };
|
|
295
|
+
}
|
|
296
|
+
if ('title' in block) {
|
|
297
|
+
return { type: 'CONTENT_TITLE', title: block.title };
|
|
298
|
+
}
|
|
299
|
+
if ('text' in block) {
|
|
300
|
+
return { type: 'CONTENT_TEXT', text: block.text };
|
|
301
|
+
}
|
|
302
|
+
throw new Error(`Cannot infer block type from fields: ${Object.keys(block).join(', ')}`);
|
|
303
|
+
}
|
|
304
|
+
// ── Build input schema ───────────────────────────────────────────────
|
|
305
|
+
const buildInputSchema = z.object({
|
|
306
|
+
owner: z.string().min(1),
|
|
307
|
+
repo: z.string().min(1),
|
|
308
|
+
sha: z.string().min(1),
|
|
309
|
+
feedTitle: z.string().min(1).max(60),
|
|
310
|
+
feedDescription: z.string().min(1),
|
|
311
|
+
forkTitle: z.string().min(1),
|
|
312
|
+
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
|
+
cards: z.array(z.object({
|
|
320
|
+
variants: z.array(z.object({
|
|
321
|
+
blocks: z.array(z.record(z.string(), z.unknown())).min(1),
|
|
322
|
+
})).min(1),
|
|
323
|
+
})).length(8),
|
|
324
|
+
});
|
|
325
|
+
// ── 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)}`;
|
|
329
|
+
// Resolve image IDs
|
|
330
|
+
const bgUrls = input.images.bg.map(resolveImageId);
|
|
331
|
+
const coverUrls = input.images.cover
|
|
332
|
+
? input.images.cover.map(resolveImageId)
|
|
333
|
+
: bgUrls;
|
|
334
|
+
const fork = {
|
|
335
|
+
_id: forkId,
|
|
336
|
+
title: input.forkTitle,
|
|
337
|
+
description: input.forkDescription,
|
|
338
|
+
imageSrc: coverUrls[0],
|
|
339
|
+
feedIds: [...(input.existingFeedIds || []), feedId],
|
|
340
|
+
...(input.actionUrl ? { actionLabel: 'View on GitHub', actionUrl: input.actionUrl } : {}),
|
|
341
|
+
};
|
|
342
|
+
const feed = {
|
|
343
|
+
_id: feedId,
|
|
344
|
+
title: input.feedTitle,
|
|
345
|
+
description: input.feedDescription,
|
|
346
|
+
imageSrc: coverUrls[0],
|
|
347
|
+
mode: 'sequential',
|
|
348
|
+
scrollDirection: 'vertical',
|
|
349
|
+
engagement: true,
|
|
350
|
+
};
|
|
351
|
+
const cards = input.cards.map((card, i) => {
|
|
352
|
+
const coverVariant = {
|
|
353
|
+
type: 'FULL_IMAGE',
|
|
354
|
+
imageSrc: coverUrls[i],
|
|
355
|
+
title: COVERS[i].title,
|
|
356
|
+
subtitle: COVERS[i].subtitle,
|
|
357
|
+
};
|
|
358
|
+
const detailVariants = card.variants.map((v) => ({
|
|
359
|
+
type: 'CONTENT',
|
|
360
|
+
backgroundSrc: bgUrls[i],
|
|
361
|
+
blocks: v.blocks.map((b) => inferBlock(b)),
|
|
362
|
+
}));
|
|
363
|
+
return {
|
|
364
|
+
_id: crypto.randomUUID(),
|
|
365
|
+
feedId,
|
|
366
|
+
order: i,
|
|
367
|
+
variants: [coverVariant, ...detailVariants],
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
return { forks: [fork], feeds: [feed], cards };
|
|
371
|
+
}
|
|
372
|
+
async function pushManifestToServer(manifest) {
|
|
373
|
+
if (!TOKEN) {
|
|
374
|
+
return {
|
|
375
|
+
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
376
|
isError: true,
|
|
166
377
|
};
|
|
167
378
|
}
|
|
168
|
-
// Save manifest locally
|
|
379
|
+
// Save manifest locally (best-effort)
|
|
169
380
|
const forkId = manifest.forks?.[0]?._id;
|
|
170
381
|
const filename = `${forkId || `manifest-${Date.now()}`}.json`;
|
|
171
382
|
const dir = join(process.cwd(), 'forkfeed');
|
|
@@ -183,43 +394,31 @@ server.tool('forkfeed_push', 'Push a generated manifest (forks, feeds, cards) to
|
|
|
183
394
|
method: 'POST',
|
|
184
395
|
headers: authHeaders(),
|
|
185
396
|
body: JSON.stringify({ manifest }),
|
|
397
|
+
signal: AbortSignal.timeout(15_000),
|
|
186
398
|
});
|
|
187
399
|
const data = await res.json();
|
|
188
400
|
if (!res.ok) {
|
|
189
401
|
return {
|
|
190
|
-
content: [
|
|
191
|
-
{
|
|
192
|
-
type: 'text',
|
|
193
|
-
text: `Push failed (${res.status}): ${data.error || JSON.stringify(data)}`,
|
|
194
|
-
},
|
|
195
|
-
],
|
|
402
|
+
content: [{ type: 'text', text: `Push failed (${res.status}): ${data.error || JSON.stringify(data)}` }],
|
|
196
403
|
isError: true,
|
|
197
404
|
};
|
|
198
405
|
}
|
|
199
406
|
const forkSummary = data.forks
|
|
200
407
|
?.map((f) => ` - ${f.title} (${f.feeds} feeds)`)
|
|
201
408
|
.join('\n');
|
|
202
|
-
// Generate QR code for the first fork's deep link
|
|
203
409
|
let qrBlock = '';
|
|
204
410
|
if (forkId) {
|
|
205
411
|
const url = `https://forkfeed.link/fork/${forkId}`;
|
|
206
412
|
try {
|
|
207
413
|
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');
|
|
414
|
+
qrBlock = ['', 'Scan to open in forkfeed:', '', qr, url].join('\n');
|
|
215
415
|
}
|
|
216
416
|
catch {
|
|
217
417
|
qrBlock = `\nLink: ${url}`;
|
|
218
418
|
}
|
|
219
419
|
}
|
|
220
420
|
return {
|
|
221
|
-
content: [
|
|
222
|
-
{
|
|
421
|
+
content: [{
|
|
223
422
|
type: 'text',
|
|
224
423
|
text: [
|
|
225
424
|
'Content pushed successfully!',
|
|
@@ -235,21 +434,216 @@ server.tool('forkfeed_push', 'Push a generated manifest (forks, feeds, cards) to
|
|
|
235
434
|
'To make it public, change visibility in the app (requires admin approval).',
|
|
236
435
|
qrBlock,
|
|
237
436
|
].join('\n'),
|
|
238
|
-
},
|
|
239
|
-
],
|
|
437
|
+
}],
|
|
240
438
|
};
|
|
241
439
|
}
|
|
242
440
|
catch (err) {
|
|
243
441
|
return {
|
|
244
|
-
content: [
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
442
|
+
content: [{ type: 'text', text: `Push failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
443
|
+
isError: true,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// ── MCP Server ─────────────────────────────────────────────────────────
|
|
448
|
+
const server = new McpServer({
|
|
449
|
+
name: 'forkfeed',
|
|
450
|
+
version: '1.0.0',
|
|
451
|
+
});
|
|
452
|
+
// ── Tool: forkfeed_guide ───────────────────────────────────────────────
|
|
453
|
+
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 () => ({
|
|
454
|
+
content: [{ type: 'text', text: GUIDE_CONTENT }],
|
|
455
|
+
}));
|
|
456
|
+
// ── Tool: forkfeed_commits ─────────────────────────────────────────────
|
|
457
|
+
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.', {
|
|
458
|
+
sha: z.string().optional().describe('Full or short SHA to analyze in detail. Omit to list recent commits.'),
|
|
459
|
+
cwd: z.string().optional().describe('Working directory. Defaults to process.cwd().'),
|
|
460
|
+
}, async ({ sha, cwd: inputCwd }) => {
|
|
461
|
+
const cwd = inputCwd || process.cwd();
|
|
462
|
+
if (!(await isGitRepo(cwd))) {
|
|
463
|
+
return { content: [{ type: 'text', text: 'Not in a git repo. Navigate to a git repository first.' }], isError: true };
|
|
464
|
+
}
|
|
465
|
+
const repoInfo = await getRepoInfo(cwd);
|
|
466
|
+
const repoLabel = repoInfo.owner ? `${repoInfo.owner}/${repoInfo.repo}` : `local/${repoInfo.repo}`;
|
|
467
|
+
// ── List mode ──
|
|
468
|
+
if (!sha) {
|
|
469
|
+
let commits;
|
|
470
|
+
try {
|
|
471
|
+
commits = await getCommitList(cwd);
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
return { content: [{ type: 'text', text: `Failed to read git log: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
475
|
+
}
|
|
476
|
+
if (commits.length === 0) {
|
|
477
|
+
return { content: [{ type: 'text', text: 'No commits found in this repository.' }] };
|
|
478
|
+
}
|
|
479
|
+
// Fetch published status (best-effort)
|
|
480
|
+
let publishedShas = new Set();
|
|
481
|
+
let existingFeedIds = [];
|
|
482
|
+
let statusWarning = '';
|
|
483
|
+
if (TOKEN) {
|
|
484
|
+
try {
|
|
485
|
+
const status = await fetchStatusData();
|
|
486
|
+
existingFeedIds = (status.feeds || []).map((f) => f.externalFeedId);
|
|
487
|
+
for (const feedId of existingFeedIds) {
|
|
488
|
+
const parts = feedId.split('-');
|
|
489
|
+
const feedSha = parts[parts.length - 1];
|
|
490
|
+
if (feedSha)
|
|
491
|
+
publishedShas.add(feedSha);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
statusWarning = '\nNote: could not check published status (network error or token issue).';
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const lines = [
|
|
499
|
+
`Repo: ${repoLabel}`,
|
|
500
|
+
'',
|
|
501
|
+
'| # | SHA | Message | Author | Date | Published |',
|
|
502
|
+
'|---|-----|---------|--------|------|-----------|',
|
|
503
|
+
];
|
|
504
|
+
for (let i = 0; i < commits.length; i++) {
|
|
505
|
+
const c = commits[i];
|
|
506
|
+
const pub = publishedShas.has(c.shortSha) ? 'yes' : '';
|
|
507
|
+
lines.push(`| ${i + 1} | ${c.shortSha} | ${c.message.slice(0, 60)} | ${c.author} | ${c.date} | ${pub} |`);
|
|
508
|
+
}
|
|
509
|
+
if (existingFeedIds.length > 0) {
|
|
510
|
+
lines.push('', `Existing feed IDs (include ALL in fork.feedIds): ${existingFeedIds.join(', ')}`);
|
|
511
|
+
}
|
|
512
|
+
if (statusWarning)
|
|
513
|
+
lines.push(statusWarning);
|
|
514
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
515
|
+
}
|
|
516
|
+
// ── Detail mode ──
|
|
517
|
+
let detail;
|
|
518
|
+
try {
|
|
519
|
+
detail = await getCommitDetail(cwd, sha);
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
return { content: [{ type: 'text', text: `Failed to read commit ${sha}: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
523
|
+
}
|
|
524
|
+
// Extract file paths from stats for tag detection
|
|
525
|
+
const filePathMatches = detail.stats.match(/^\s*(.+?)\s+\|/gm) || [];
|
|
526
|
+
const filePaths = filePathMatches.map((m) => m.trim().replace(/\s+\|$/, ''));
|
|
527
|
+
const tags = detectTags(detail.message, filePaths);
|
|
528
|
+
const images = filterImagesByTags(tags);
|
|
529
|
+
const lines = [
|
|
530
|
+
`## Commit: ${detail.shortSha} - ${detail.message}`,
|
|
531
|
+
`Author: ${detail.author} | Date: ${detail.date}`,
|
|
532
|
+
`Repo: ${repoLabel}`,
|
|
533
|
+
repoInfo.owner ? `GitHub: https://github.com/${repoInfo.owner}/${repoInfo.repo}` : '',
|
|
534
|
+
'',
|
|
535
|
+
'## File Stats',
|
|
536
|
+
detail.stats,
|
|
537
|
+
'',
|
|
538
|
+
'## Diff',
|
|
539
|
+
detail.diff,
|
|
540
|
+
'',
|
|
541
|
+
`## Suggested Images (tags: ${tags.join(', ')})`,
|
|
542
|
+
'',
|
|
543
|
+
'Scene images (for covers + inline):',
|
|
544
|
+
...images.scenes,
|
|
545
|
+
'',
|
|
546
|
+
'Background images (for card backgrounds):',
|
|
547
|
+
...images.backgrounds,
|
|
548
|
+
].filter(Boolean);
|
|
549
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
550
|
+
});
|
|
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
|
+
// ── Tool: forkfeed_push ────────────────────────────────────────────────
|
|
576
|
+
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
|
+
manifest: z
|
|
578
|
+
.object({
|
|
579
|
+
forks: z.array(z.any()),
|
|
580
|
+
feeds: z.array(z.any()),
|
|
581
|
+
cards: z.array(z.any()),
|
|
582
|
+
})
|
|
583
|
+
.describe('The complete manifest with forks, feeds, and cards arrays'),
|
|
584
|
+
}, async ({ manifest }) => {
|
|
585
|
+
// Validate manifest structure before pushing
|
|
586
|
+
const parsed = manifestSchema.safeParse(manifest);
|
|
587
|
+
if (!parsed.success) {
|
|
588
|
+
const issues = parsed.error.issues
|
|
589
|
+
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
|
|
590
|
+
.join('\n');
|
|
591
|
+
return {
|
|
592
|
+
content: [{ type: 'text', text: `Manifest validation failed:\n${issues}\n\nFix the issues above and try again.` }],
|
|
593
|
+
isError: true,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
const crossErr = crossValidate(parsed.data);
|
|
597
|
+
if (crossErr) {
|
|
598
|
+
return {
|
|
599
|
+
content: [{ type: 'text', text: `Manifest cross-reference errors:\n${crossErr}\n\nFix the issues above and try again.` }],
|
|
600
|
+
isError: true,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return pushManifestToServer(parsed.data);
|
|
604
|
+
});
|
|
605
|
+
// ── 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'),
|
|
608
|
+
push: z.boolean().optional().describe('Push immediately after building (default: true)'),
|
|
609
|
+
}, async ({ content, push }) => {
|
|
610
|
+
const shouldPush = push !== false;
|
|
611
|
+
// Build the full manifest from simplified input
|
|
612
|
+
let manifest;
|
|
613
|
+
try {
|
|
614
|
+
manifest = buildManifest(content);
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
return {
|
|
618
|
+
content: [{ type: 'text', text: `Build failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
619
|
+
isError: true,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
// Validate the assembled manifest
|
|
623
|
+
const parsed = manifestSchema.safeParse(manifest);
|
|
624
|
+
if (!parsed.success) {
|
|
625
|
+
const issues = parsed.error.issues
|
|
626
|
+
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
|
|
627
|
+
.join('\n');
|
|
628
|
+
return {
|
|
629
|
+
content: [{ type: 'text', text: `Built manifest failed validation:\n${issues}` }],
|
|
250
630
|
isError: true,
|
|
251
631
|
};
|
|
252
632
|
}
|
|
633
|
+
const crossErr = crossValidate(parsed.data);
|
|
634
|
+
if (crossErr) {
|
|
635
|
+
return {
|
|
636
|
+
content: [{ type: 'text', text: `Built manifest cross-reference errors:\n${crossErr}` }],
|
|
637
|
+
isError: true,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
if (shouldPush) {
|
|
641
|
+
return pushManifestToServer(parsed.data);
|
|
642
|
+
}
|
|
643
|
+
// Return the manifest for review
|
|
644
|
+
return {
|
|
645
|
+
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)}` }],
|
|
646
|
+
};
|
|
253
647
|
});
|
|
254
648
|
// ── Tool: forkfeed_status ──────────────────────────────────────────────
|
|
255
649
|
server.tool('forkfeed_status', 'Check your current forkfeed content: which forks and feeds you have published.', {}, async () => {
|
|
@@ -265,21 +659,7 @@ server.tool('forkfeed_status', 'Check your current forkfeed content: which forks
|
|
|
265
659
|
};
|
|
266
660
|
}
|
|
267
661
|
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
|
-
}
|
|
662
|
+
const data = await fetchStatusData();
|
|
283
663
|
if (!data.forks?.length) {
|
|
284
664
|
return {
|
|
285
665
|
content: [
|
|
@@ -327,26 +707,13 @@ server.prompt('forkfeed', 'Turn GitHub commits into swipeable forkfeed content.
|
|
|
327
707
|
type: 'text',
|
|
328
708
|
text: `Turn the commits in this repo into forkfeed content. Follow these steps exactly:
|
|
329
709
|
|
|
330
|
-
1. Call **forkfeed_guide** and **
|
|
331
|
-
2.
|
|
332
|
-
3.
|
|
333
|
-
4.
|
|
334
|
-
5.
|
|
335
|
-
- Fork: ID, title, description, imageSrc
|
|
336
|
-
- Feed: ID, title, description, imageSrc, mode: "sequential", scrollDirection: "vertical", engagement: true
|
|
337
|
-
- Assign 8 unique cover images (img*) and 8 unique backgrounds (bg*) to cards 0-7
|
|
338
|
-
- Pre-generate 8 card UUIDs
|
|
339
|
-
- Split remaining scene images into 8 non-overlapping pools for inline CONTENT_IMAGE use
|
|
340
|
-
- Include ALL existing feed IDs in the fork's feedIds array (see "Incremental updates" in guide)
|
|
341
|
-
6. Generate all 8 cards IN PARALLEL using the Agent tool. Launch ALL 8 agents in a SINGLE message.
|
|
342
|
-
Each agent prompt must include: the card number, section type and rules (from the guide), the full commit diff, assigned cover/background/inline images, the card UUID, and the feed ID.
|
|
343
|
-
Each agent must return ONLY the raw card JSON object: { "_id": "...", "feedId": "...", "order": N, "variants": [...] }
|
|
344
|
-
Tell each agent: "Do NOT use any tools, scripts, or commands. Just output the JSON directly as text."
|
|
345
|
-
See "Parallel card generation" in the guide for details.
|
|
346
|
-
7. Assemble the full manifest from the skeleton + 8 card JSON results. Validate against the checklist.
|
|
347
|
-
8. Call **forkfeed_push** with the complete manifest.
|
|
710
|
+
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.
|
|
714
|
+
5. Call **forkfeed_build** with the simplified content (push defaults to true).
|
|
348
715
|
|
|
349
|
-
Start now
|
|
716
|
+
Start now.`,
|
|
350
717
|
},
|
|
351
718
|
},
|
|
352
719
|
],
|