1
0
Fork 0
mirror of https://github.com/pnx/dotfiles synced 2026-08-16 00:48:14 +02:00

opencode: add OAC

This commit is contained in:
Henrik Hautakoski 2026-07-08 10:26:58 +02:00
parent a9fc1ca56e
commit 863c63a84b
216 changed files with 39215 additions and 37 deletions

168
opencode/tool/env/index.ts vendored Normal file
View file

@ -0,0 +1,168 @@
import { readFile } from "fs/promises"
import { resolve } from "path"
/**
* Configuration for environment variable loading
*/
export interface EnvLoaderConfig {
/** Custom paths to search for .env files (relative to current working directory) */
searchPaths?: string[]
/** Whether to log when environment variables are loaded */
verbose?: boolean
/** Whether to override existing environment variables */
override?: boolean
}
/**
* Default search paths for .env files
*/
const DEFAULT_ENV_PATHS = [
'./.env',
'../.env',
'../../.env',
'../plugin/.env',
'../../../.env'
]
/**
* Load environment variables from .env files
* Searches multiple common locations for .env files and loads them into process.env
*
* @param config Configuration options
* @returns Object containing loaded environment variables
*/
export async function loadEnvVariables(config: EnvLoaderConfig = {}): Promise<Record<string, string>> {
const {
searchPaths = DEFAULT_ENV_PATHS,
verbose = false,
override = false
} = config
const loadedVars: Record<string, string> = {}
for (const envPath of searchPaths) {
try {
const fullPath = resolve(envPath)
const content = await readFile(fullPath, 'utf8')
if (verbose) {
console.log(`Checking .env file: ${envPath}`)
}
// Parse .env file content
const lines = content.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) {
const [key, ...valueParts] = trimmed.split('=')
const value = valueParts.join('=').trim()
// Remove quotes if present
const cleanValue = value.replace(/^["']|["']$/g, '')
if (key && cleanValue && (override || !process.env[key])) {
process.env[key] = cleanValue
loadedVars[key] = cleanValue
if (verbose) {
console.log(`Loaded ${key} from ${envPath}`)
}
}
}
}
} catch (error) {
// File doesn't exist or can't be read, continue to next
if (verbose) {
console.log(`Could not read ${envPath}: ${error.message}`)
}
}
}
return loadedVars
}
/**
* Get a specific environment variable with automatic .env file loading
*
* @param varName Name of the environment variable
* @param config Configuration options
* @returns The environment variable value or null if not found
*/
export async function getEnvVariable(varName: string, config: EnvLoaderConfig = {}): Promise<string | null> {
// First check if it's already in the environment
let value = process.env[varName]
if (!value) {
// Try to load from .env files
const loadedVars = await loadEnvVariables(config)
value = loadedVars[varName] || process.env[varName]
}
return value || null
}
/**
* Get a required environment variable with automatic .env file loading
* Throws an error if the variable is not found
*
* @param varName Name of the environment variable
* @param config Configuration options
* @returns The environment variable value
* @throws Error if the variable is not found
*/
export async function getRequiredEnvVariable(varName: string, config: EnvLoaderConfig = {}): Promise<string> {
const value = await getEnvVariable(varName, config)
if (!value) {
const searchPaths = config.searchPaths || DEFAULT_ENV_PATHS
throw new Error(`${varName} not found. Please set it in your environment or .env file.
To fix this:
1. Add to .env file: ${varName}=your_value_here
2. Or export it: export ${varName}=your_value_here
Current working directory: ${process.cwd()}
Searched paths: ${searchPaths.join(', ')}
Environment variables available: ${Object.keys(process.env).filter(k => k.includes(varName.split('_')[0])).join(', ') || 'none matching'}`)
}
return value
}
/**
* Load multiple required environment variables at once
*
* @param varNames Array of environment variable names
* @param config Configuration options
* @returns Object with variable names as keys and values as values
* @throws Error if any variable is not found
*/
export async function getRequiredEnvVariables(varNames: string[], config: EnvLoaderConfig = {}): Promise<Record<string, string>> {
const result: Record<string, string> = {}
// Load all .env files first
await loadEnvVariables(config)
// Check each required variable
for (const varName of varNames) {
const value = process.env[varName]
if (!value) {
throw new Error(`Required environment variable ${varName} not found. Please set it in your environment or .env file.`)
}
result[varName] = value
}
return result
}
/**
* Utility function specifically for API keys
*
* @param apiKeyName Name of the API key environment variable
* @param config Configuration options
* @returns The API key value
* @throws Error if the API key is not found
*/
export async function getApiKey(apiKeyName: string, config: EnvLoaderConfig = {}): Promise<string> {
return getRequiredEnvVariable(apiKeyName, config)
}

View file

@ -0,0 +1,379 @@
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