diff --git a/src/integrations/codex-image.js b/src/integrations/codex-image.js new file mode 100644 index 0000000..1a8eee9 --- /dev/null +++ b/src/integrations/codex-image.js @@ -0,0 +1,175 @@ +import { spawn } from "node:child_process"; +import { mkdir, stat } from "node:fs/promises"; +import path from "node:path"; + +const DEFAULT_OUTPUT_DIR = new URL("../../generated-images", import.meta.url).pathname; +const DEFAULT_CODEX_PATH = "codex"; +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; + +function getOutputDir() { + return process.env.CODEX_IMAGE_OUTPUT_DIR || DEFAULT_OUTPUT_DIR; +} + +function getCodexPath() { + return process.env.CODEX_CLI_PATH || DEFAULT_CODEX_PATH; +} + +function safeFilename(filename) { + const fallback = `codex-${new Date().toISOString().replaceAll(/[:.]/g, "-")}.png`; + const base = path.basename(filename || fallback).replaceAll(/[^a-zA-Z0-9._-]/g, "-"); + const trimmed = base.replaceAll(/-+/g, "-").replaceAll(/^\.+/g, ""); + return trimmed || fallback; +} + +function parseJsonFromOutput(output) { + const trimmed = output.trim(); + if (!trimmed) { + return null; + } + + try { + return JSON.parse(trimmed); + } catch { + const match = trimmed.match(/\{[\s\S]*\}$/); + if (!match) { + return null; + } + try { + return JSON.parse(match[0]); + } catch { + return null; + } + } +} + +function runCodex(codexPath, args, timeoutMs) { + return new Promise((resolve, reject) => { + const child = spawn(codexPath, args, { + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, timeoutMs); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("close", (code, signal) => { + clearTimeout(timeout); + if (timedOut) { + reject(new Error(`timed out after ${timeoutMs}ms. ${stderr.trim()}`)); + return; + } + if (code !== 0) { + reject(new Error(stderr.trim() || stdout.trim() || `exited with ${signal || code}`)); + return; + } + resolve({ stdout, stderr }); + }); + + child.stdin.end(); + }); +} + +export function getCodexImageConfigStatus() { + return { + codexCliPath: getCodexPath(), + outputDir: getOutputDir(), + model: process.env.CODEX_IMAGE_MODEL || "(Codex CLI default)", + timeoutMs: Number.parseInt(process.env.CODEX_IMAGE_TIMEOUT_MS || String(DEFAULT_TIMEOUT_MS), 10), + }; +} + +export async function generateCodexImage({ + prompt, + filename, + size, + quality, + style, + referenceImage, +}) { + const outputDir = getOutputDir(); + await mkdir(outputDir, { recursive: true }); + + const outputFilename = safeFilename(filename); + const outputPath = path.join(outputDir, outputFilename); + const codexPath = getCodexPath(); + const timeoutMs = Number.parseInt(process.env.CODEX_IMAGE_TIMEOUT_MS || String(DEFAULT_TIMEOUT_MS), 10); + + const details = [ + size ? `Requested size/aspect: ${size}` : null, + quality ? `Requested quality: ${quality}` : null, + style ? `Requested style: ${style}` : null, + ].filter(Boolean).join("\n"); + + const workerPrompt = [ + "Use $imagegen to generate exactly one raster image.", + `Save the final image file at this exact absolute path: ${outputPath}`, + "Do not modify any other files.", + "After saving the file, respond only with JSON matching this shape:", + `{"ok":true,"path":"${outputPath.replaceAll("\\", "\\\\")}","note":"short description"}`, + details ? `Generation details:\n${details}` : null, + `Image prompt:\n${prompt}`, + ].filter(Boolean).join("\n\n"); + + const args = [ + "exec", + "--ephemeral", + "--sandbox", + "workspace-write", + "--enable", + "image_generation", + "-C", + new URL("../..", import.meta.url).pathname, + ]; + + if (process.env.CODEX_IMAGE_MODEL) { + args.push("--model", process.env.CODEX_IMAGE_MODEL); + } + if (referenceImage) { + args.push("--image", referenceImage); + } + + args.push(workerPrompt); + + let stdout = ""; + let stderr = ""; + try { + const result = await runCodex(codexPath, args, timeoutMs); + stdout = result.stdout; + stderr = result.stderr; + } catch (error) { + throw new Error(`Codex image generation failed. ${error.message}`); + } + + try { + const file = await stat(outputPath); + const parsed = parseJsonFromOutput(stdout); + return { + ok: true, + path: outputPath, + filename: outputFilename, + bytes: file.size, + codexCliPath: codexPath, + note: parsed?.note || null, + stderr: stderr.trim() || null, + }; + } catch { + throw new Error(`Codex completed but did not create ${outputPath}. Output: ${stdout.trim() || "(empty)"}`); + } +} diff --git a/src/integrations/gemini-chrome-prompt.js b/src/integrations/gemini-chrome-prompt.js new file mode 100644 index 0000000..74aa2ae --- /dev/null +++ b/src/integrations/gemini-chrome-prompt.js @@ -0,0 +1,67 @@ +import path from "node:path"; + +const DEFAULT_OUTPUT_DIR = new URL("../../generated-images", import.meta.url).pathname; +const DEFAULT_PROFILE_NAME = "ReynaFamilyBot"; + +function getOutputDir() { + return process.env.GEMINI_CHROME_IMAGE_OUTPUT_DIR || process.env.CODEX_IMAGE_OUTPUT_DIR || DEFAULT_OUTPUT_DIR; +} + +function getProfileName() { + return process.env.GEMINI_CHROME_PROFILE_NAME || DEFAULT_PROFILE_NAME; +} + +function safeFilename(filename) { + const fallback = `gemini-chrome-${new Date().toISOString().replaceAll(/[:.]/g, "-")}.png`; + const base = path.basename(filename || fallback).replaceAll(/[^a-zA-Z0-9._-]/g, "-"); + const trimmed = base.replaceAll(/-+/g, "-").replaceAll(/^\.+/g, ""); + const name = trimmed || fallback; + return name.endsWith(".png") ? name : `${name}.png`; +} + +function codexPrompt({ prompt, outputPath, profileName }) { + return [ + "Use the Chrome skill, not Playwright and not MacMiniMCP browser tools.", + "", + "Goal: generate an image in Gemini using my Chrome profile named `" + profileName + "`, then save the downloaded image locally.", + "", + "Steps:", + "1. Connect to Chrome through the Codex Chrome Extension.", + "2. Verify the selected Chrome browser metadata has `profileName: \"" + profileName + "\"`. If not, stop and tell me.", + "3. Open or create a Gemini tab at https://gemini.google.com/app.", + "4. If Gemini shows the first-run notice, click `Got it`.", + "5. Submit this image prompt:", + "", + prompt, + "", + "6. Wait until Gemini finishes and shows `Download full size image`.", + "7. Click `Download full size image`.", + "8. Find the newest `Gemini_Generated_Image_*.png` in `/Users/adolforeyna/Downloads`.", + "9. Copy it to:", + " `" + outputPath + "`", + "10. Show me the saved path and render the image in the response.", + "", + "Do not expose browser control through MCP. Do not use arbitrary browsing. Only use Chrome for this Gemini image-generation task.", + ].join("\n"); +} + +export function getGeminiChromePromptConfigStatus() { + return { + outputDir: getOutputDir(), + profileName: getProfileName(), + note: "This MCP tool builds a Codex prompt. It does not control Chrome itself because the Chrome skill is only available inside an active Codex session.", + }; +} + +export function buildGeminiChromePrompt({ prompt, filename } = {}) { + const outputFilename = safeFilename(filename); + const outputPath = path.join(getOutputDir(), outputFilename); + const profileName = getProfileName(); + + return { + prompt: codexPrompt({ prompt, outputPath, profileName }), + outputPath, + filename: outputFilename, + profileName, + }; +} diff --git a/src/integrations/gemini-image.js b/src/integrations/gemini-image.js new file mode 100644 index 0000000..5ee2ae2 --- /dev/null +++ b/src/integrations/gemini-image.js @@ -0,0 +1,99 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const defaultOutputDir = path.join(projectRoot, "generated-images"); +const defaultModel = "gemini-3.1-flash-image"; +const interactionsUrl = "https://generativelanguage.googleapis.com/v1beta/interactions"; + +function outputDir() { + return process.env.GEMINI_IMAGE_OUTPUT_DIR || defaultOutputDir; +} + +function apiKey() { + return process.env.GEMINI_API_KEY || ""; +} + +function safeFilename(name) { + const fallback = `gemini-${new Date().toISOString().replace(/[:.]/g, "-")}.png`; + const base = path.basename(name || fallback).replace(/[^a-zA-Z0-9._-]/g, "-"); + if (!base) { + return fallback; + } + return base.endsWith(".png") ? base : `${base}.png`; +} + +function buildResponseFormat({ aspectRatio, imageSize }) { + if (!aspectRatio && !imageSize) { + return undefined; + } + return { + type: "image", + mime_type: "image/png", + ...(aspectRatio ? { aspect_ratio: aspectRatio } : {}), + ...(imageSize ? { image_size: imageSize } : {}), + }; +} + +export function getGeminiImageConfigStatus() { + return { + configured: Boolean(apiKey()), + outputDir: outputDir(), + defaultModel, + }; +} + +export async function generateGeminiImage({ + prompt, + filename, + model = defaultModel, + aspectRatio, + imageSize, + useGoogleSearch = false, +} = {}) { + const key = apiKey(); + if (!key) { + throw new Error("GEMINI_API_KEY is required for gemini_image_generate."); + } + + const responseFormat = buildResponseFormat({ aspectRatio, imageSize }); + const body = { + model, + input: [{ type: "text", text: prompt }], + ...(responseFormat ? { response_format: responseFormat } : {}), + ...(useGoogleSearch ? { tools: [{ type: "google_search" }] } : {}), + }; + + const response = await fetch(interactionsUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-goog-api-key": key, + }, + body: JSON.stringify(body), + }); + + const data = await response.json().catch(() => null); + if (!response.ok) { + const message = data?.error?.message || response.statusText || "Gemini image generation failed."; + throw new Error(`Gemini API error ${response.status}: ${message}`); + } + + const image = data?.output_image; + if (!image?.data) { + throw new Error("Gemini API did not return output_image.data."); + } + + const destinationDir = outputDir(); + await fs.mkdir(destinationDir, { recursive: true }); + const destination = path.join(destinationDir, safeFilename(filename)); + await fs.writeFile(destination, Buffer.from(image.data, "base64")); + + return { + path: destination, + model, + mimeType: image.mime_type || "image/png", + interactionId: data.id, + }; +} diff --git a/src/server.js b/src/server.js index 6b34d4c..2556e31 100644 --- a/src/server.js +++ b/src/server.js @@ -11,7 +11,19 @@ import { readContact, searchContacts, } from "./integrations/contacts.js"; +import { + generateCodexImage, + getCodexImageConfigStatus, +} from "./integrations/codex-image.js"; import { getDecoConfigStatus, getDecoStats } from "./integrations/deco.js"; +import { + buildGeminiChromePrompt, + getGeminiChromePromptConfigStatus, +} from "./integrations/gemini-chrome-prompt.js"; +import { + generateGeminiImage, + getGeminiImageConfigStatus, +} from "./integrations/gemini-image.js"; import { createNote, listNotes, readNote } from "./integrations/notes.js"; import { createReminder, @@ -119,14 +131,14 @@ export function createMacMiniMcpServer() { server.tool( "reminders_list_lists", - "List Reminders lists.", + "List Reminders lists, including account context and assignment metadata availability for shared lists.", {}, handled(listReminderLists), ); server.tool( "reminders_list", - "List reminders with optional list and completion filters.", + "List reminders with optional list and completion filters. Returns assignment details for shared-list reminders when available, plus title/notes-derived assignment hints.", { list: z.string().optional().describe("Exact Reminders list name."), completed: z.boolean().nullable().default(false).describe("Use null to return both complete and incomplete items."), @@ -208,7 +220,7 @@ export function createMacMiniMcpServer() { server.tool( "deco_list_clients", - "List online TP-Link Deco clients with hostname, IP, MAC, connection type, and current up/down speeds.", + "List online TP-Link Deco clients with hostname, IP, MAC, connection type, current up/down speeds, and linked mesh node when available.", {}, handled(() => getDecoStats("clients")), ); @@ -241,5 +253,64 @@ export function createMacMiniMcpServer() { handled(() => getSpeechApiStatus()), ); + server.tool( + "codex_image_get_config_status", + "Show local Codex CLI image generation configuration.", + {}, + handled(getCodexImageConfigStatus), + ); + + server.tool( + "codex_image_generate", + "Generate an image using this Mac's Codex CLI image generation and save it to the MacMiniMCP generated-images directory.", + { + prompt: z.string().min(1).max(8000).describe("Text prompt describing the image to generate."), + filename: z.string().min(1).max(200).optional().describe("Optional local filename. Directory components are ignored."), + size: z.string().max(100).optional().describe("Optional size or aspect request, such as 1024x1024, 16:9, or square."), + quality: z.string().max(100).optional().describe("Optional quality request, such as draft, standard, or high."), + style: z.string().max(500).optional().describe("Optional visual style guidance."), + referenceImage: z.string().optional().describe("Optional absolute path to a local reference image for Codex CLI --image."), + }, + handled(generateCodexImage), + ); + + server.tool( + "gemini_image_get_config_status", + "Show Gemini image generation configuration without revealing the API key.", + {}, + handled(getGeminiImageConfigStatus), + ); + + server.tool( + "gemini_image_generate", + "Generate one image with the Gemini API and save it to the local generated-images directory. This tool does not expose browser control.", + { + prompt: z.string().min(1).max(8000).describe("Text prompt describing the image to generate."), + filename: z.string().min(1).max(200).optional().describe("Optional local PNG filename. Directory components are ignored."), + model: z.string().min(1).max(100).default("gemini-3.1-flash-image"), + aspectRatio: z.enum(["1:1", "3:4", "4:3", "9:16", "16:9"]).optional(), + imageSize: z.enum(["1K", "2K", "4K"]).optional(), + useGoogleSearch: z.boolean().default(false).describe("Allow Gemini to use Google Search for prompts that need current real-world context."), + }, + handled(generateGeminiImage), + ); + + server.tool( + "gemini_chrome_prompt_get_config_status", + "Show configuration for the Codex Chrome-skill Gemini image prompt builder.", + {}, + handled(getGeminiChromePromptConfigStatus), + ); + + server.tool( + "gemini_chrome_prompt_build", + "Build a ready-to-run Codex prompt for generating one Gemini web-app image through the verified ReynaFamilyBot Chrome profile. This MCP tool does not control Chrome.", + { + prompt: z.string().min(1).max(8000).describe("Text prompt describing the image to generate in Gemini."), + filename: z.string().min(1).max(200).optional().describe("Optional local PNG filename. Directory components are ignored."), + }, + handled(buildGeminiChromePrompt), + ); + return server; }