Add BMAD, Claude, Cursor, and OpenCode configuration directories along with AGENTS.md documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import { tool } from "@opencode-ai/plugin/tool"
|
|
import { mkdir } from "fs/promises"
|
|
import { join, dirname, basename, extname, resolve } from "path"
|
|
import { getApiKey } from "../env"
|
|
|
|
// Function to detect if we're in test mode
|
|
function isTestMode(): boolean {
|
|
// Only enable test mode when explicitly set
|
|
return process.env.GEMINI_TEST_MODE === 'true'
|
|
}
|
|
|
|
// Function to get Gemini API key with automatic .env loading
|
|
async function getGeminiApiKey(): Promise<string> {
|
|
if (isTestMode()) {
|
|
return 'test-api-key'
|
|
}
|
|
return getApiKey('GEMINI_API_KEY')
|
|
}
|
|
|
|
interface ImageConfig {
|
|
outputDir?: string;
|
|
useTimestamp?: boolean;
|
|
preserveOriginal?: boolean;
|
|
customName?: string;
|
|
}
|
|
|
|
async function parseImageInput(input: string) {
|
|
// Accepts file path ("./img.png") or data URL ("data:image/png;base64,...")
|
|
if (input.startsWith("data:")) {
|
|
const base64 = input.split(",")[1]
|
|
const mime = input.substring(5, input.indexOf(";"))
|
|
return { mime, base64 }
|
|
}
|
|
// Treat as file path
|
|
const file = Bun.file(input)
|
|
const arr = await file.arrayBuffer()
|
|
const base64 = Buffer.from(arr).toString("base64")
|
|
// Best-effort mime
|
|
const mime = file.type || "image/png"
|
|
return { mime, base64 }
|
|
}
|
|
|
|
async function ensureDirectoryExists(dirPath: string) {
|
|
try {
|
|
await mkdir(dirPath, { recursive: true })
|
|
} catch (error) {
|
|
// Directory might already exist, that's fine
|
|
}
|
|
}
|
|
|
|
function getDateBasedPath(baseDir?: string): string {
|
|
// Default to assets/images at repo root
|
|
if (!baseDir) {
|
|
// Navigate from .opencode/tool/ to repo root, then to assets/images
|
|
baseDir = resolve(process.cwd(), "../../assets/images")
|
|
}
|
|
const today = new Date().toISOString().split('T')[0] // YYYY-MM-DD format
|
|
return join(baseDir, today)
|
|
}
|
|
|
|
async function getUniqueFilename(directory: string, baseName: string, extension: string, isEdit: boolean = false): Promise<string> {
|
|
await ensureDirectoryExists(directory)
|
|
|
|
if (!isEdit) {
|
|
// For generations, use timestamp if file exists
|
|
const baseFilename = join(directory, `${baseName}${extension}`)
|
|
const fileExists = await Bun.file(baseFilename).exists()
|
|
|
|
if (!fileExists) {
|
|
return baseFilename
|
|
}
|
|
|
|
// Add timestamp if file exists
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) // Remove milliseconds and Z
|
|
return join(directory, `${baseName}_${timestamp}${extension}`)
|
|
}
|
|
|
|
// For edits, use incremental numbering
|
|
let counter = 1
|
|
let filename: string
|
|
|
|
do {
|
|
const editSuffix = `_edit_${counter.toString().padStart(3, '0')}`
|
|
filename = join(directory, `${baseName}${editSuffix}${extension}`)
|
|
counter++
|
|
} while (await Bun.file(filename).exists())
|
|
|
|
return filename
|
|
}
|
|
|
|
export async function generateImage(prompt: string, config: ImageConfig = {}): Promise<string> {
|
|
const apiKey = await getGeminiApiKey()
|
|
|
|
// Test mode - return mock response without API call
|
|
if (isTestMode()) {
|
|
const baseDir = config.outputDir || getDateBasedPath()
|
|
const generationsDir = join(baseDir, "generations")
|
|
let baseName = config.customName || "generated"
|
|
if (baseName.endsWith('.png') || baseName.endsWith('.jpg') || baseName.endsWith('.jpeg')) {
|
|
baseName = baseName.substring(0, baseName.lastIndexOf('.'))
|
|
}
|
|
const outputPath = await getUniqueFilename(generationsDir, baseName, ".png", false)
|
|
|
|
return `[TEST MODE] Would generate image: ${outputPath} for prompt: "${prompt.substring(0, 50)}..."`
|
|
}
|
|
|
|
const body = {
|
|
contents: [{
|
|
parts: [{ text: prompt }]
|
|
}],
|
|
}
|
|
|
|
const res = await fetch(
|
|
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image-preview:generateContent",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-goog-api-key": apiKey,
|
|
},
|
|
body: JSON.stringify(body),
|
|
}
|
|
)
|
|
|
|
if (!res.ok) {
|
|
const errorText = await res.text()
|
|
throw new Error(`API error (${res.status}): ${errorText}`)
|
|
}
|
|
|
|
const json = await res.json()
|
|
|
|
// Look for image data in the response
|
|
const candidates = json?.candidates
|
|
if (!candidates || candidates.length === 0) {
|
|
throw new Error("No candidates in response")
|
|
}
|
|
|
|
const parts = candidates[0]?.content?.parts
|
|
if (!parts || parts.length === 0) {
|
|
throw new Error("No parts in response")
|
|
}
|
|
|
|
let b64 = null
|
|
for (const part of parts) {
|
|
if (part.inlineData?.data) {
|
|
b64 = part.inlineData.data
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!b64) {
|
|
throw new Error("No image data returned from Nano Banana model")
|
|
}
|
|
|
|
// Determine output path
|
|
const baseDir = config.outputDir || getDateBasedPath()
|
|
const generationsDir = join(baseDir, "generations")
|
|
|
|
// Generate filename (remove extension if already present)
|
|
let baseName = config.customName || "generated"
|
|
if (baseName.endsWith('.png') || baseName.endsWith('.jpg') || baseName.endsWith('.jpeg')) {
|
|
baseName = baseName.substring(0, baseName.lastIndexOf('.'))
|
|
}
|
|
const extension = ".png"
|
|
const outputPath = await getUniqueFilename(generationsDir, baseName, extension, false)
|
|
|
|
console.log(`Saving generated image to: ${outputPath}`)
|
|
await Bun.write(outputPath, Buffer.from(b64, "base64"))
|
|
|
|
const fileExists = await Bun.file(outputPath).exists()
|
|
if (!fileExists) {
|
|
throw new Error(`Failed to save file to ${outputPath}`)
|
|
}
|
|
|
|
const stats = await Bun.file(outputPath).stat()
|
|
return `Generated image saved: ${outputPath} (${stats.size} bytes)`
|
|
}
|
|
|
|
export async function editImage(imagePath: string, prompt: string, config: ImageConfig = {}): Promise<string> {
|
|
const apiKey = await getGeminiApiKey()
|
|
|
|
// Test mode - return mock response without API call
|
|
if (isTestMode()) {
|
|
const baseDir = config.outputDir || getDateBasedPath()
|
|
const editsDir = join(baseDir, "edits")
|
|
const originalName = basename(imagePath, extname(imagePath))
|
|
let baseName = config.customName || originalName
|
|
if (baseName.endsWith('.png') || baseName.endsWith('.jpg') || baseName.endsWith('.jpeg')) {
|
|
baseName = baseName.substring(0, baseName.lastIndexOf('.'))
|
|
}
|
|
const outputPath = await getUniqueFilename(editsDir, baseName, ".png", true)
|
|
|
|
return `[TEST MODE] Would edit image: ${imagePath} -> ${outputPath} with prompt: "${prompt.substring(0, 50)}..."`
|
|
}
|
|
|
|
// Parse the input image
|
|
const { mime, base64 } = await parseImageInput(imagePath)
|
|
|
|
const body = {
|
|
contents: [{
|
|
parts: [
|
|
{ text: prompt },
|
|
{ inlineData: { mimeType: mime, data: base64 } }
|
|
]
|
|
}],
|
|
}
|
|
|
|
const res = await fetch(
|
|
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image-preview:generateContent",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-goog-api-key": apiKey,
|
|
},
|
|
body: JSON.stringify(body),
|
|
}
|
|
)
|
|
|
|
if (!res.ok) {
|
|
const errorText = await res.text()
|
|
throw new Error(`API error (${res.status}): ${errorText}`)
|
|
}
|
|
|
|
const json = await res.json()
|
|
|
|
// Look for image data in the response
|
|
const candidates = json?.candidates
|
|
if (!candidates || candidates.length === 0) {
|
|
throw new Error("No candidates in response")
|
|
}
|
|
|
|
const parts = candidates[0]?.content?.parts
|
|
if (!parts || parts.length === 0) {
|
|
throw new Error("No parts in response")
|
|
}
|
|
|
|
let b64 = null
|
|
for (const part of parts) {
|
|
if (part.inlineData?.data) {
|
|
b64 = part.inlineData.data
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!b64) {
|
|
throw new Error("No image data returned from Nano Banana model")
|
|
}
|
|
|
|
// Determine output path
|
|
const baseDir = config.outputDir || getDateBasedPath()
|
|
const editsDir = join(baseDir, "edits")
|
|
|
|
// Extract original filename without extension
|
|
const originalName = basename(imagePath, extname(imagePath))
|
|
let baseName = config.customName || originalName
|
|
if (baseName.endsWith('.png') || baseName.endsWith('.jpg') || baseName.endsWith('.jpeg')) {
|
|
baseName = baseName.substring(0, baseName.lastIndexOf('.'))
|
|
}
|
|
const extension = ".png"
|
|
|
|
const outputPath = await getUniqueFilename(editsDir, baseName, extension, true)
|
|
|
|
console.log(`Saving edited image to: ${outputPath}`)
|
|
await Bun.write(outputPath, Buffer.from(b64, "base64"))
|
|
|
|
const fileExists = await Bun.file(outputPath).exists()
|
|
if (!fileExists) {
|
|
throw new Error(`Failed to save file to ${outputPath}`)
|
|
}
|
|
|
|
const stats = await Bun.file(outputPath).stat()
|
|
return `Edited image saved: ${outputPath} (${stats.size} bytes)`
|
|
}
|
|
|
|
export async function analyzeImage(imagePath: string, question: string): Promise<string> {
|
|
const apiKey = await getGeminiApiKey()
|
|
|
|
// Test mode - return mock response without API call
|
|
if (isTestMode()) {
|
|
return `[TEST MODE] Would analyze image: ${imagePath} with question: "${question.substring(0, 50)}..." - Mock analysis: This is a test image analysis response.`
|
|
}
|
|
|
|
const { mime, base64 } = await parseImageInput(imagePath)
|
|
|
|
const res = await fetch(
|
|
"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-goog-api-key": apiKey,
|
|
},
|
|
body: JSON.stringify({
|
|
contents: [{
|
|
parts: [
|
|
{ text: question },
|
|
{ inlineData: { mimeType: mime, data: base64 } }
|
|
]
|
|
}],
|
|
}),
|
|
}
|
|
)
|
|
|
|
if (!res.ok) {
|
|
const errorText = await res.text()
|
|
throw new Error(`API error (${res.status}): ${errorText}`)
|
|
}
|
|
|
|
const json = await res.json()
|
|
const text = json?.candidates?.[0]?.content?.parts?.[0]?.text
|
|
if (!text) {
|
|
throw new Error("No analysis returned")
|
|
}
|
|
|
|
return text
|
|
}
|
|
|
|
// Tool for generating images from text
|
|
export const generate = tool({
|
|
description: "Generate an image using Gemini Nano Banana from text prompt",
|
|
args: {
|
|
prompt: tool.schema.string().describe("Text description of the image to generate"),
|
|
outputDir: tool.schema.string().optional().describe("Custom output directory (default: ./generated-images/YYYY-MM-DD/)"),
|
|
filename: tool.schema.string().optional().describe("Custom filename (default: generated)"),
|
|
},
|
|
async execute(args, context) {
|
|
try {
|
|
const config: ImageConfig = {
|
|
outputDir: args.outputDir,
|
|
customName: args.filename,
|
|
}
|
|
return await generateImage(args.prompt, config)
|
|
} catch (error) {
|
|
return `Error: ${error.message}`
|
|
}
|
|
},
|
|
})
|
|
|
|
// Tool for editing existing images
|
|
export const edit = tool({
|
|
description: "Edit an existing image using Gemini Nano Banana",
|
|
args: {
|
|
image: tool.schema.string().describe("File path or data URL of image to edit"),
|
|
prompt: tool.schema.string().describe("Edit instruction"),
|
|
outputDir: tool.schema.string().optional().describe("Custom output directory (default: ./generated-images/YYYY-MM-DD/)"),
|
|
filename: tool.schema.string().optional().describe("Custom filename (default: original name with _edit_XXX)"),
|
|
},
|
|
async execute(args, context) {
|
|
try {
|
|
const config: ImageConfig = {
|
|
outputDir: args.outputDir,
|
|
customName: args.filename,
|
|
}
|
|
return await editImage(args.image, args.prompt, config)
|
|
} catch (error) {
|
|
return `Error: ${error.message}`
|
|
}
|
|
},
|
|
})
|
|
|
|
// Tool for analyzing images
|
|
export const analyze = tool({
|
|
description: "Analyze an image using Gemini (text analysis only)",
|
|
args: {
|
|
image: tool.schema.string().describe("File path or data URL of image to analyze"),
|
|
question: tool.schema.string().describe("What to analyze about the image"),
|
|
},
|
|
async execute(args, context) {
|
|
try {
|
|
return await analyzeImage(args.image, args.question)
|
|
} catch (error) {
|
|
return `Error: ${error.message}`
|
|
}
|
|
},
|
|
})
|
|
|
|
// Default export for backward compatibility
|
|
export default edit |