Merge Mac + Pi: add system_get_info/system_speech_api_status + restore codex-image/gemini-image/gemini-chrome-prompt tools from Mac
Mac mini is on macOS 26.5.2 with SpeechAnalyzer available - verified via sw_vers Needed to sync diverged repos: Pi had system tools, Mac had image tools
This commit is contained in:
@@ -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)"}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+74
-3
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user