From fa9c957823bed9c3817756143303811e4c636aed Mon Sep 17 00:00:00 2001 From: Steven Jieli Wu Date: Fri, 1 May 2026 03:33:23 +0000 Subject: [PATCH 1/6] feat: port buddy_share logic (Task 1) --- package.json | 4 +- src/cli/snapshot-cli.ts | 29 ++++++ src/lib/share.ts | 210 ++++++++++++++++++++++++++++++++++++++++ src/lib/snapshot.ts | 42 ++++++++ src/server/index.ts | 29 ++++++ 5 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 src/cli/snapshot-cli.ts create mode 100644 src/lib/share.ts create mode 100644 src/lib/snapshot.ts diff --git a/package.json b/package.json index 68175ae..f95fa76 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "bin": { "buddy-statusline": "dist/statusline-wrapper.js", "buddy-onboard": "dist/cli/onboard.js", - "buddy-doctor": "dist/cli/doctor-cli.js" + "buddy-doctor": "dist/cli/doctor-cli.js", + "buddy-snapshot": "dist/cli/snapshot-cli.js", + "buddy-share": "dist/cli/snapshot-cli.js" }, "files": [ "dist", diff --git a/src/cli/snapshot-cli.ts b/src/cli/snapshot-cli.ts new file mode 100644 index 0000000..99a6978 --- /dev/null +++ b/src/cli/snapshot-cli.ts @@ -0,0 +1,29 @@ +import { initDb, db } from '../db/schema.js'; +import { loadCompanion } from '../lib/companion.js'; +import { captureSnapshot } from '../lib/snapshot.js'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +async function main() { + initDb(); + const row = db.prepare("SELECT * FROM companions LIMIT 1").get() as any; + if (!row) { + console.error("No buddy found. Hatch one first!"); + process.exit(1); + } + + const companion = loadCompanion(row)!; + const outPath = process.argv[2] || join(process.cwd(), 'buddy_snapshot.png'); + + console.log(`Generating snapshot for ${companion.name}...`); + await captureSnapshot(companion, outPath); + console.log(`Snapshot saved to: ${outPath}`); +} + +main().catch(err => { + console.error("Failed to generate snapshot:", err); + process.exit(1); +}); diff --git a/src/lib/share.ts b/src/lib/share.ts new file mode 100644 index 0000000..a79d69d --- /dev/null +++ b/src/lib/share.ts @@ -0,0 +1,210 @@ +import { type Companion, STAT_NAMES, RARITY_STARS } from './types.js'; +import { levelProgress } from './leveling.js'; + +export function renderShareHtml(companion: Companion): string { + const stars = RARITY_STARS[companion.rarity]; + const { level, currentXp, neededXp } = levelProgress(companion.xp); + const xpPercent = Math.min(100, Math.floor((currentXp / neededXp) * 100)); + + const statsHtml = STAT_NAMES.map(s => ` +
+ ${s} +
+
+
+ ${companion.stats[s]} +
+ `).join(''); + + return ` + + + + + + +
+
+
${stars} ${companion.rarity}
+
${companion.species}
+
+ +
+
+
RENDER_SPRITE_HERE
+
+
+

${companion.name}

+
"${companion.personalityBio}"
+
+
+ +
+ ${statsHtml} +
+ + + + +
+ + + `; +} diff --git a/src/lib/snapshot.ts b/src/lib/snapshot.ts new file mode 100644 index 0000000..7ffcff7 --- /dev/null +++ b/src/lib/snapshot.ts @@ -0,0 +1,42 @@ +import puppeteer from 'puppeteer'; +import { type Companion, RARITY_STARS } from '../lib/types.js'; +import { renderShareHtml } from '../lib/share.js'; +import { renderSprite } from '../lib/species.js'; +import { join } from 'path'; +import { writeFileSync } from 'fs'; + +export async function captureSnapshot(companion: Companion, outPath: string) { + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + const page = await browser.newPage(); + + // Set viewport to card size plus padding + await page.setViewport({ width: 600, height: 600, deviceScaleFactor: 2 }); + + let html = renderShareHtml(companion); + + // Inject the actual sprite + const spriteLines = renderSprite(companion); + const spriteHtml = spriteLines.join('\n') + .replace(/&/g, '&') + .replace(//g, '>'); + + html = html.replace('RENDER_SPRITE_HERE', spriteHtml); + + await page.setContent(html); + + // Wait for font/styles to settle + await new Promise(r => setTimeout(r, 100)); + + const cardElement = await page.$('.card'); + if (cardElement) { + await cardElement.screenshot({ path: outPath }); + } else { + await page.screenshot({ path: outPath }); + } + + await browser.close(); +} diff --git a/src/server/index.ts b/src/server/index.ts index 611f470..9c21ac5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -25,6 +25,7 @@ import { join, dirname } from "path"; import { homedir } from "os"; import { loadCompanion, writeBuddyStatus, createCompanion } from "../lib/companion.js"; import { renderCard, hatchAnimation } from "../lib/card.js"; +import { captureSnapshot } from "../lib/snapshot.js"; import { BUDDY_STATUS_PATH } from "../lib/constants.js"; import { runDiagnostics, formatReport } from "../lib/doctor.js"; import { @@ -296,6 +297,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { description: "Run diagnostics on your Buddy installation. Checks companion state, database, MCP registration, statusline, hooks, and prompt injection. Returns a report you can paste into bug reports. IMPORTANT: Include the full diagnostic report verbatim in a code block in your response.", inputSchema: { type: "object", properties: {} }, }, + { + name: "buddy_share", + description: "Generate a beautiful shareable snapshot of your Buddy's current status and card. Returns the local path to the generated image.", + inputSchema: { + type: "object", + properties: { + user_id: { type: "string", description: "Optional user ID for bones." } + }, + }, + }, ], }; }); @@ -709,6 +720,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { content: [{ type: "text", text: '```\n' + report + '\n```' }] }; } + if (name === "buddy_share") { + const { user_id } = args as { user_id?: string }; + const row = db.prepare("SELECT * FROM companions LIMIT 1").get() as any; + if (!row) return { content: [{ type: "text", text: "Hatch a buddy first!" }] }; + + const companion = loadCompanion(row, user_id || row.user_id)!; + const outPath = join(homedir(), '.buddy', `share_${companion.name.toLowerCase()}.png`); + + await captureSnapshot(companion, outPath); + + return { + content: [ + { type: "text", text: `📸 Snapshot generated for ${companion.name}!` }, + { type: "text", text: `Path: ${outPath}` } + ], + }; + } + throw new Error(`Tool not found: ${name}`); }); From e65e4518f79ba71a991ea1c5f67e8db15f3979aa Mon Sep 17 00:00:00 2001 From: Steven Jieli Wu Date: Fri, 1 May 2026 03:46:29 +0000 Subject: [PATCH 2/6] feat: keep buddy_share logic but remove from MCP tool list (moved to skill) --- src/server/index.ts | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 9c21ac5..ce8bac5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -297,16 +297,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { description: "Run diagnostics on your Buddy installation. Checks companion state, database, MCP registration, statusline, hooks, and prompt injection. Returns a report you can paste into bug reports. IMPORTANT: Include the full diagnostic report verbatim in a code block in your response.", inputSchema: { type: "object", properties: {} }, }, - { - name: "buddy_share", - description: "Generate a beautiful shareable snapshot of your Buddy's current status and card. Returns the local path to the generated image.", - inputSchema: { - type: "object", - properties: { - user_id: { type: "string", description: "Optional user ID for bones." } - }, - }, - }, ], }; }); @@ -720,24 +710,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { content: [{ type: "text", text: '```\n' + report + '\n```' }] }; } - if (name === "buddy_share") { - const { user_id } = args as { user_id?: string }; - const row = db.prepare("SELECT * FROM companions LIMIT 1").get() as any; - if (!row) return { content: [{ type: "text", text: "Hatch a buddy first!" }] }; - - const companion = loadCompanion(row, user_id || row.user_id)!; - const outPath = join(homedir(), '.buddy', `share_${companion.name.toLowerCase()}.png`); - - await captureSnapshot(companion, outPath); - - return { - content: [ - { type: "text", text: `📸 Snapshot generated for ${companion.name}!` }, - { type: "text", text: `Path: ${outPath}` } - ], - }; - } - throw new Error(`Tool not found: ${name}`); }); From ab5414b18e50daa7b3348c3e4303a569b09f8cb0 Mon Sep 17 00:00:00 2001 From: Steven Jieli Wu Date: Fri, 1 May 2026 04:11:46 +0000 Subject: [PATCH 3/6] style: polish share card design with UI-UX Pro Max principles --- src/cli/snapshot-cli.ts | 7 +- src/lib/share.ts | 252 +++++++++++++++++++++++++++------------- src/lib/snapshot.ts | 19 ++- 3 files changed, 192 insertions(+), 86 deletions(-) diff --git a/src/cli/snapshot-cli.ts b/src/cli/snapshot-cli.ts index 99a6978..e703730 100644 --- a/src/cli/snapshot-cli.ts +++ b/src/cli/snapshot-cli.ts @@ -17,9 +17,14 @@ async function main() { const companion = loadCompanion(row)!; const outPath = process.argv[2] || join(process.cwd(), 'buddy_snapshot.png'); + const message = process.argv[3]; + + const deltaStat = process.argv[4]; + const deltaPoints = parseInt(process.argv[5] || '0'); + const delta = deltaStat ? { stat: deltaStat, points: deltaPoints } : undefined; console.log(`Generating snapshot for ${companion.name}...`); - await captureSnapshot(companion, outPath); + await captureSnapshot(companion, outPath, message, delta); console.log(`Snapshot saved to: ${outPath}`); } diff --git a/src/lib/share.ts b/src/lib/share.ts index a79d69d..7ba0310 100644 --- a/src/lib/share.ts +++ b/src/lib/share.ts @@ -1,178 +1,270 @@ import { type Companion, STAT_NAMES, RARITY_STARS } from './types.js'; import { levelProgress } from './leveling.js'; -export function renderShareHtml(companion: Companion): string { +export type ShareDelta = { + stat: string; + points: number; +}; + +export function renderShareHtml(companion: Companion, message?: string, delta?: ShareDelta): string { const stars = RARITY_STARS[companion.rarity]; const { level, currentXp, neededXp } = levelProgress(companion.xp); - const xpPercent = Math.min(100, Math.floor((currentXp / neededXp) * 100)); + + const statsHtml = STAT_NAMES.map(s => { + const isDelta = delta && delta.stat.toUpperCase() === s; + const baseValue = isDelta ? Math.max(0, companion.stats[s] - delta.points) : companion.stats[s]; + const displayValue = companion.stats[s]; + + return ` +
+ ${s} +
+
+ ${isDelta ? `
` : ''} +
+
+ ${isDelta ? `+${delta.points}` : ''} + ${displayValue} +
+
+ `; + }).join(''); - const statsHtml = STAT_NAMES.map(s => ` -
- ${s} -
-
+ const bubbleHtml = message ? ` +
+
+ ${message}
- ${companion.stats[s]} +
- `).join(''); + ` : ''; return ` @@ -183,22 +275,24 @@ export function renderShareHtml(companion: Companion): string {
${companion.species}
-
+
RENDER_SPRITE_HERE
-
-

${companion.name}

-
"${companion.personalityBio}"
-
+ ${bubbleHtml} +
+ +
+

${companion.name}

+
"${companion.personalityBio}"
-
+
${statsHtml}
diff --git a/src/lib/snapshot.ts b/src/lib/snapshot.ts index 7ffcff7..e7cae6e 100644 --- a/src/lib/snapshot.ts +++ b/src/lib/snapshot.ts @@ -1,21 +1,21 @@ import puppeteer from 'puppeteer'; import { type Companion, RARITY_STARS } from '../lib/types.js'; -import { renderShareHtml } from '../lib/share.js'; +import { renderShareHtml, type ShareDelta } from '../lib/share.js'; import { renderSprite } from '../lib/species.js'; import { join } from 'path'; import { writeFileSync } from 'fs'; -export async function captureSnapshot(companion: Companion, outPath: string) { +export async function captureSnapshot(companion: Companion, outPath: string, message?: string, delta?: ShareDelta) { const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const page = await browser.newPage(); - // Set viewport to card size plus padding - await page.setViewport({ width: 600, height: 600, deviceScaleFactor: 2 }); + // Set viewport to card size plus padding (increased height to ensure bottom isn't cut) + await page.setViewport({ width: 600, height: 900, deviceScaleFactor: 2 }); - let html = renderShareHtml(companion); + let html = renderShareHtml(companion, message, delta); // Inject the actual sprite const spriteLines = renderSprite(companion); @@ -33,10 +33,17 @@ export async function captureSnapshot(companion: Companion, outPath: string) { const cardElement = await page.$('.card'); if (cardElement) { - await cardElement.screenshot({ path: outPath }); + // If bubble is present, we need a larger bounding box or just screenshot the page + const bubble = await page.$('.bubble-container'); + if (bubble) { + await page.screenshot({ path: outPath, fullPage: false }); + } else { + await cardElement.screenshot({ path: outPath }); + } } else { await page.screenshot({ path: outPath }); } await browser.close(); } + From 971fddc27f0d96ae7d0023d5f03dd5c0c02fa286 Mon Sep 17 00:00:00 2001 From: Steven Wu Date: Fri, 1 May 2026 23:33:38 -0400 Subject: [PATCH 4/6] fix: cleanup buddy_share snapshot system Remove unused captureSnapshot import from MCP server, fix wrong relative import paths in snapshot.ts, replace setTimeout with document.fonts.ready, add try/finally around browser lifecycle, fix bubble overflow CSS, switch CLI to parseArgs with named flags, remove duplicate bin entry. Co-Authored-By: Claude Opus 4.6 --- package.json | 3 +-- src/cli/snapshot-cli.ts | 42 ++++++++++++++++++++--------- src/lib/share.ts | 5 ++-- src/lib/snapshot.ts | 60 +++++++++++++++-------------------------- src/server/index.ts | 1 - 5 files changed, 56 insertions(+), 55 deletions(-) diff --git a/package.json b/package.json index f95fa76..cd591e1 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,7 @@ "buddy-statusline": "dist/statusline-wrapper.js", "buddy-onboard": "dist/cli/onboard.js", "buddy-doctor": "dist/cli/doctor-cli.js", - "buddy-snapshot": "dist/cli/snapshot-cli.js", - "buddy-share": "dist/cli/snapshot-cli.js" + "buddy-snapshot": "dist/cli/snapshot-cli.js" }, "files": [ "dist", diff --git a/src/cli/snapshot-cli.ts b/src/cli/snapshot-cli.ts index e703730..f003223 100644 --- a/src/cli/snapshot-cli.ts +++ b/src/cli/snapshot-cli.ts @@ -1,13 +1,35 @@ import { initDb, db } from '../db/schema.js'; import { loadCompanion } from '../lib/companion.js'; import { captureSnapshot } from '../lib/snapshot.js'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { join } from 'path'; +import { parseArgs } from 'util'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +function usage(): never { + console.log(`Usage: buddy-snapshot [options] + +Options: + -o, --output Output PNG path (default: ./buddy_snapshot.png) + -m, --message Speech bubble message + --stat Delta stat name (e.g. WISDOM) + --points Delta points (default: 0) + -h, --help Show this help`); + process.exit(0); +} async function main() { + const { values } = parseArgs({ + options: { + output: { type: 'string', short: 'o' }, + message: { type: 'string', short: 'm' }, + stat: { type: 'string' }, + points: { type: 'string' }, + help: { type: 'boolean', short: 'h' }, + }, + strict: true, + }); + + if (values.help) usage(); + initDb(); const row = db.prepare("SELECT * FROM companions LIMIT 1").get() as any; if (!row) { @@ -16,15 +38,11 @@ async function main() { } const companion = loadCompanion(row)!; - const outPath = process.argv[2] || join(process.cwd(), 'buddy_snapshot.png'); - const message = process.argv[3]; - - const deltaStat = process.argv[4]; - const deltaPoints = parseInt(process.argv[5] || '0'); - const delta = deltaStat ? { stat: deltaStat, points: deltaPoints } : undefined; - + const outPath = values.output || join(process.cwd(), 'buddy_snapshot.png'); + const delta = values.stat ? { stat: values.stat, points: parseInt(values.points || '0') } : undefined; + console.log(`Generating snapshot for ${companion.name}...`); - await captureSnapshot(companion, outPath, message, delta); + await captureSnapshot(companion, outPath, values.message, delta); console.log(`Snapshot saved to: ${outPath}`); } diff --git a/src/lib/share.ts b/src/lib/share.ts index 7ba0310..ba018f3 100644 --- a/src/lib/share.ts +++ b/src/lib/share.ts @@ -110,7 +110,8 @@ export function renderShareHtml(companion: Companion, message?: string, delta?: align-items: flex-end; margin-bottom: 5px; position: relative; - height: 140px; + min-height: 140px; + overflow: visible; } .sprite-box pre { margin: 0; @@ -125,7 +126,7 @@ export function renderShareHtml(companion: Companion, message?: string, delta?: .bubble-container { position: absolute; top: 5px; - left: 62%; /* Moved slightly right */ + right: -10px; max-width: 170px; z-index: 10; } diff --git a/src/lib/snapshot.ts b/src/lib/snapshot.ts index e7cae6e..9cde4bf 100644 --- a/src/lib/snapshot.ts +++ b/src/lib/snapshot.ts @@ -1,49 +1,33 @@ import puppeteer from 'puppeteer'; -import { type Companion, RARITY_STARS } from '../lib/types.js'; -import { renderShareHtml, type ShareDelta } from '../lib/share.js'; -import { renderSprite } from '../lib/species.js'; -import { join } from 'path'; -import { writeFileSync } from 'fs'; +import { type Companion } from './types.js'; +import { renderShareHtml, type ShareDelta } from './share.js'; +import { renderSprite } from './species.js'; + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} export async function captureSnapshot(companion: Companion, outPath: string, message?: string, delta?: ShareDelta) { - const browser = await puppeteer.launch({ + const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }); - const page = await browser.newPage(); - - // Set viewport to card size plus padding (increased height to ensure bottom isn't cut) - await page.setViewport({ width: 600, height: 900, deviceScaleFactor: 2 }); - let html = renderShareHtml(companion, message, delta); - - // Inject the actual sprite - const spriteLines = renderSprite(companion); - const spriteHtml = spriteLines.join('\n') - .replace(/&/g, '&') - .replace(//g, '>'); - - html = html.replace('RENDER_SPRITE_HERE', spriteHtml); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 600, height: 900, deviceScaleFactor: 2 }); - await page.setContent(html); - - // Wait for font/styles to settle - await new Promise(r => setTimeout(r, 100)); + let html = renderShareHtml(companion, message, delta); - const cardElement = await page.$('.card'); - if (cardElement) { - // If bubble is present, we need a larger bounding box or just screenshot the page - const bubble = await page.$('.bubble-container'); - if (bubble) { - await page.screenshot({ path: outPath, fullPage: false }); - } else { - await cardElement.screenshot({ path: outPath }); - } - } else { - await page.screenshot({ path: outPath }); - } + const spriteLines = renderSprite(companion); + const spriteHtml = escapeHtml(spriteLines.join('\n')); + html = html.replace('RENDER_SPRITE_HERE', spriteHtml); - await browser.close(); -} + await page.setContent(html); + await page.evaluateHandle(() => document.fonts.ready); + await page.screenshot({ path: outPath, fullPage: false }); + } finally { + await browser.close(); + } +} diff --git a/src/server/index.ts b/src/server/index.ts index ce8bac5..611f470 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -25,7 +25,6 @@ import { join, dirname } from "path"; import { homedir } from "os"; import { loadCompanion, writeBuddyStatus, createCompanion } from "../lib/companion.js"; import { renderCard, hatchAnimation } from "../lib/card.js"; -import { captureSnapshot } from "../lib/snapshot.js"; import { BUDDY_STATUS_PATH } from "../lib/constants.js"; import { runDiagnostics, formatReport } from "../lib/doctor.js"; import { From b5df3739ace3d7adb71e26b0842efa0b60ae10b9 Mon Sep 17 00:00:00 2001 From: Steven Wu Date: Fri, 1 May 2026 23:36:25 -0400 Subject: [PATCH 5/6] fix: address remaining review items on buddy_share - Remove buddy-snapshot bin entry (dev/skill-only, puppeteer is a devDep) - Escape message HTML to prevent XSS in Puppeteer context - Clamp delta bar width so it can't overflow the stat bar Co-Authored-By: Claude Opus 4.6 --- package.json | 3 +-- src/lib/share.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index cd591e1..68175ae 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,7 @@ "bin": { "buddy-statusline": "dist/statusline-wrapper.js", "buddy-onboard": "dist/cli/onboard.js", - "buddy-doctor": "dist/cli/doctor-cli.js", - "buddy-snapshot": "dist/cli/snapshot-cli.js" + "buddy-doctor": "dist/cli/doctor-cli.js" }, "files": [ "dist", diff --git a/src/lib/share.ts b/src/lib/share.ts index ba018f3..e6e7c1e 100644 --- a/src/lib/share.ts +++ b/src/lib/share.ts @@ -1,6 +1,10 @@ import { type Companion, STAT_NAMES, RARITY_STARS } from './types.js'; import { levelProgress } from './leveling.js'; +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + export type ShareDelta = { stat: string; points: number; @@ -19,8 +23,8 @@ export function renderShareHtml(companion: Companion, message?: string, delta?:
${s}
-
- ${isDelta ? `
` : ''} +
+ ${isDelta ? `
` : ''}
${isDelta ? `+${delta.points}` : ''} @@ -33,7 +37,7 @@ export function renderShareHtml(companion: Companion, message?: string, delta?: const bubbleHtml = message ? `
- ${message} + ${escapeHtml(message)}
From dd830d8281989ecd5370db2e76e8ad61c8b2de26 Mon Sep 17 00:00:00 2001 From: Steven Wu Date: Fri, 1 May 2026 23:37:20 -0400 Subject: [PATCH 6/6] fix: restore buddy-snapshot bin entry Co-Authored-By: Claude Opus 4.6 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 68175ae..cd591e1 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "bin": { "buddy-statusline": "dist/statusline-wrapper.js", "buddy-onboard": "dist/cli/onboard.js", - "buddy-doctor": "dist/cli/doctor-cli.js" + "buddy-doctor": "dist/cli/doctor-cli.js", + "buddy-snapshot": "dist/cli/snapshot-cli.js" }, "files": [ "dist",