forkfeed-mcp 1.0.16 → 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/index.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
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
- // ── MCP Server ─────────────────────────────────────────────────────────
100
- const server = new McpServer({
101
- name: 'forkfeed',
102
- version: '1.0.0',
103
- });
104
- // ── Tool: forkfeed_guide ───────────────────────────────────────────────
105
- 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 () => ({
106
- content: [{ type: 'text', text: GUIDE_CONTENT }],
107
- }));
108
- // ── Tool: forkfeed_images ──────────────────────────────────────────────
109
- 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 () => {
110
- const scenes = IMAGE_CATALOG.filter((i) => i.id.startsWith('img'));
111
- const bgs = IMAGE_CATALOG.filter((i) => i.id.startsWith('bg'));
112
- const lines = [
113
- '# IT Scenes Image Catalog',
114
- '',
115
- 'Match images to content by tags and name. Use img* for covers and inline images, bg* for card backgrounds.',
116
- '',
117
- 'Tags: deploy, git, disaster, debug, hype, victory, beginner, language, lifestyle, workplace, sarcastic, general',
118
- '',
119
- '## Scene Images (covers + inline)',
120
- '',
121
- ...scenes.map((i) => `${i.id} | ${i.name} | ${i.tags} | ${i.url}`),
122
- '',
123
- '## Background Images',
124
- '',
125
- ...bgs.map((i) => `${i.id} | ${i.name} | ${i.tags} | ${i.url}`),
126
- ];
127
- return { content: [{ type: 'text', text: lines.join('\n') }] };
128
- });
129
- // ── Tool: forkfeed_push ────────────────────────────────────────────────
130
- 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.', {
131
- manifest: z
132
- .object({
133
- forks: z.array(z.any()),
134
- feeds: z.array(z.any()),
135
- cards: z.array(z.any()),
136
- })
137
- .describe('The complete manifest with forks, feeds, and cards arrays'),
138
- }, async ({ manifest }) => {
139
- if (!TOKEN) {
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
- content: [
142
- {
143
- type: 'text',
144
- text: 'Error: FORKFEED_TOKEN not set. Get your API token from forkfeed.link/admin/user/token and add it to your .mcp.json env.',
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
- // Validate manifest structure before pushing
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
- content: [{ type: 'text', text: `Manifest validation failed:\n${issues}\n\nFix the issues above and try again.` }],
158
- isError: true,
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
- const crossErr = crossValidate(parsed.data);
162
- if (crossErr) {
271
+ if ('label' in block && 'action' in block && 'target' in block) {
163
272
  return {
164
- content: [{ type: 'text', text: `Manifest cross-reference errors:\n${crossErr}\n\nFix the issues above and try again.` }],
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 before uploading (best-effort, doesn't block push)
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
- type: 'text',
247
- text: `Push failed: ${err instanceof Error ? err.message : String(err)}`,
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 res = await fetch(`${APP_SERVER_URL}/api/content/status`, {
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,13 +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**, **forkfeed_images**, and **forkfeed_status** to load the guide, image catalog, and existing content.
331
- 2. Detect the repo from the working directory. Show recent commits in a table with a column indicating whether each already has a published feed (match 7-char SHA from status feed IDs). Ask which ONE commit to process (default: latest). One commit at a time, never more. Do NOT ask about image style.
332
- 3. Fetch the commit diff and stats via git.
333
- 4. Generate the complete manifest JSON directly. Do NOT use any scripts, tools, or commands to generate UUIDs or content. Generate everything (card UUIDs, all text, all JSON) purely in your response. Include ALL existing feed IDs in the fork's feedIds array (see "Incremental updates" in guide). Output the full manifest as a single JSON block.
334
- 5. 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).
335
715
 
336
- Start now. Load the guide, images, and status, then detect the repo.`,
716
+ Start now.`,
337
717
  },
338
718
  },
339
719
  ],