From 0d4169797f13a9aa5239f110de8c6459e30f1dae Mon Sep 17 00:00:00 2001 From: Penny777 Date: Wed, 17 Dec 2025 12:06:39 +1300 Subject: [PATCH 1/2] feat: add Gemini CLI support for remote mobile control - Add src/gemini/ module with complete integration - Implement GeminiQuery class for process management - Support stream-json output format parsing - Add permission handling for tool calls - Update CLI help text and README documentation - Add 'happy gemini' subcommand with -m model option This enables users to control Google Gemini CLI from the Happy mobile app, similar to existing Claude Code and Codex support. --- README.md | 32 ++- src/gemini/index.ts | 7 + src/gemini/runGemini.ts | 415 +++++++++++++++++++++++++++++++++++++++ src/gemini/sdk/index.ts | 7 + src/gemini/sdk/query.ts | 360 +++++++++++++++++++++++++++++++++ src/gemini/sdk/stream.ts | 84 ++++++++ src/gemini/sdk/utils.ts | 140 +++++++++++++ src/gemini/types.ts | 241 +++++++++++++++++++++++ src/index.ts | 53 ++++- 9 files changed, 1323 insertions(+), 16 deletions(-) create mode 100644 src/gemini/index.ts create mode 100644 src/gemini/runGemini.ts create mode 100644 src/gemini/sdk/index.ts create mode 100644 src/gemini/sdk/query.ts create mode 100644 src/gemini/sdk/stream.ts create mode 100644 src/gemini/sdk/utils.ts create mode 100644 src/gemini/types.ts diff --git a/README.md b/README.md index d8ab661a..786a1dfc 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,15 @@ # Happy -Code on the go controlling claude code from your mobile device. +Code on the go controlling AI coding assistants from your mobile device. Free. Open source. Code anywhere. +## Supported AI Assistants + +- **Claude Code** (Anthropic) - `happy` or `happy claude` +- **Codex** (OpenAI) - `happy codex` +- **Gemini CLI** (Google) - `happy gemini` ✨ NEW + ## Installation ```bash @@ -12,19 +18,31 @@ npm install -g happy-coder ## Usage +### Claude Code (Default) ```bash happy ``` +### OpenAI Codex +```bash +happy codex +``` + +### Google Gemini CLI +```bash +happy gemini +``` + This will: -1. Start a Claude Code session +1. Start an AI coding session 2. Display a QR code to connect from your mobile device -3. Allow real-time session sharing between Claude Code and your mobile app +3. Allow real-time session sharing between the AI and your mobile app ## Commands - `happy auth` – Manage authentication -- `happy codex` – Start Codex mode +- `happy codex` – Start Codex mode (OpenAI) +- `happy gemini` – Start Gemini mode (Google) - `happy connect` – Store AI vendor API keys in Happy cloud - `happy notify` – Send a push notification to your devices - `happy daemon` – Manage background service @@ -34,7 +52,7 @@ This will: - `-h, --help` - Show help - `-v, --version` - Show version -- `-m, --model ` - Claude model to use (default: sonnet) +- `-m, --model ` - Model to use (e.g., sonnet, gemini-2.5-pro) - `-p, --permission-mode ` - Permission mode: auto, default, or plan - `--claude-env KEY=VALUE` - Set environment variable for Claude Code (e.g., for [claude-code-router](https://github.com/musistudio/claude-code-router)) - `--claude-arg ARG` - Pass additional argument to Claude CLI @@ -53,7 +71,9 @@ This will: - Required by `eventsource-parser@3.0.5`, which is required by `@modelcontextprotocol/sdk`, which we used to implement permission forwarding to mobile app -- Claude CLI installed & logged in (`claude` command available in PATH) +- For Claude: Claude CLI installed & logged in (`claude` command available in PATH) +- For Codex: OpenAI Codex CLI installed +- For Gemini: Gemini CLI installed (`npm install -g @google/gemini-cli`) & authenticated with Google account ## License diff --git a/src/gemini/index.ts b/src/gemini/index.ts new file mode 100644 index 00000000..3346860d --- /dev/null +++ b/src/gemini/index.ts @@ -0,0 +1,7 @@ +/** + * Gemini module exports + */ + +export { runGemini, type GeminiStartOptions } from './runGemini'; +export * from './types'; +export * from './sdk'; diff --git a/src/gemini/runGemini.ts b/src/gemini/runGemini.ts new file mode 100644 index 00000000..0e653c82 --- /dev/null +++ b/src/gemini/runGemini.ts @@ -0,0 +1,415 @@ +/** + * Main entry point for the Gemini command with remote control support + * Adapted from runCodex.ts for Gemini CLI integration + */ + +import { randomUUID } from 'node:crypto'; +import os from 'node:os'; +import { join, resolve } from 'node:path'; + +import { ApiClient } from '@/api/api'; +import { logger } from '@/ui/logger'; +import { Credentials, readSettings } from '@/persistence'; +import { AgentState, Metadata } from '@/api/types'; +import packageJson from '../../package.json'; +import { configuration } from '@/configuration'; +import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; +import { initialMachineMetadata } from '@/daemon/run'; +import { startHappyServer } from '@/claude/utils/startHappyServer'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; +import { projectPath } from '@/projectPath'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { hashObject } from '@/utils/deterministicJson'; +import { stopCaffeinate, startCaffeinate } from '@/utils/caffeinate'; +import { delay } from '@/utils/time'; + +import { geminiQuery } from './sdk/query'; +import { isGeminiInstalled } from './sdk/utils'; +import type { GeminiEnhancedMode, GeminiPermissionMode, GeminiSDKMessage } from './types'; + +export interface GeminiStartOptions { + credentials: Credentials; + startedBy?: 'daemon' | 'terminal'; + model?: string; + permissionMode?: GeminiPermissionMode; +} + +/** + * Main entry point for Gemini command + */ +export async function runGemini(opts: GeminiStartOptions): Promise { + // Check if Gemini CLI is installed + const geminiInstalled = await isGeminiInstalled(); + if (!geminiInstalled) { + console.error('❌ Gemini CLI is not installed.'); + console.error(''); + console.error('To install Gemini CLI, run:'); + console.error(' npm install -g @google/gemini-cli'); + console.error(''); + console.error('Then authenticate with your Google account:'); + console.error(' gemini'); + console.error(''); + process.exit(1); + } + + // + // Define session + // + + const sessionTag = randomUUID(); + const api = await ApiClient.create(opts.credentials); + + logger.debug(`[gemini] Starting with options: startedBy=${opts.startedBy || 'terminal'}`); + + // + // Machine setup + // + + const settings = await readSettings(); + let machineId = settings?.machineId; + if (!machineId) { + console.error(`[START] No machine ID found in settings. Please run 'happy auth login' first.`); + process.exit(1); + } + logger.debug(`Using machineId: ${machineId}`); + await api.getOrCreateMachine({ + machineId, + metadata: initialMachineMetadata + }); + + // + // Create session + // + + let state: AgentState = { + controlledByUser: false, + }; + let metadata: Metadata = { + path: process.cwd(), + host: os.hostname(), + version: packageJson.version, + os: os.platform(), + machineId: machineId, + homeDir: os.homedir(), + happyHomeDir: configuration.happyHomeDir, + happyLibDir: projectPath(), + happyToolsDir: resolve(projectPath(), 'tools', 'unpacked'), + startedFromDaemon: opts.startedBy === 'daemon', + hostPid: process.pid, + startedBy: opts.startedBy || 'terminal', + lifecycleState: 'running', + lifecycleStateSince: Date.now(), + flavor: 'gemini' // Mark as Gemini session + }; + const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + const session = api.sessionSyncClient(response); + + // Report to daemon + try { + logger.debug(`[START] Reporting session ${response.id} to daemon`); + const result = await notifyDaemonSessionStarted(response.id, metadata); + if (result.error) { + logger.debug(`[START] Failed to report to daemon:`, result.error); + } + } catch (error) { + logger.debug('[START] Failed to report to daemon:', error); + } + + // Message queue for remote messages + const messageQueue = new MessageQueue2((mode) => hashObject({ + permissionMode: mode.permissionMode, + model: mode.model, + })); + + // Track current settings + let currentPermissionMode: GeminiPermissionMode = opts.permissionMode || 'default'; + let currentModel: string | undefined = opts.model; + + // Handle incoming user messages from mobile app + session.onUserMessage((message) => { + // Update permission mode if provided + if (message.meta?.permissionMode) { + const validModes: GeminiPermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + if (validModes.includes(message.meta.permissionMode as GeminiPermissionMode)) { + currentPermissionMode = message.meta.permissionMode as GeminiPermissionMode; + logger.debug(`[Gemini] Permission mode updated to: ${currentPermissionMode}`); + } + } + + // Update model if provided + if (message.meta?.hasOwnProperty('model')) { + currentModel = message.meta.model || undefined; + logger.debug(`[Gemini] Model updated to: ${currentModel || 'default'}`); + } + + const enhancedMode: GeminiEnhancedMode = { + permissionMode: currentPermissionMode, + model: currentModel, + }; + messageQueue.push(message.content.text, enhancedMode); + }); + + // Keep-alive handling + let thinking = false; + session.keepAlive(thinking, 'remote'); + const keepAliveInterval = setInterval(() => { + session.keepAlive(thinking, 'remote'); + }, 2000); + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + try { + api.push().sendToAllDevices( + "Ready!", + 'Gemini is waiting for your command', + { sessionId: session.sessionId } + ); + } catch (pushError) { + logger.debug('[Gemini] Failed to send ready push', pushError); + } + }; + + // + // Abort handling + // + + let abortController = new AbortController(); + let shouldExit = false; + + async function handleAbort() { + logger.debug('[Gemini] Abort requested'); + try { + abortController.abort(); + messageQueue.reset(); + } catch (error) { + logger.debug('[Gemini] Error during abort:', error); + } finally { + abortController = new AbortController(); + } + } + + const handleKillSession = async () => { + logger.debug('[Gemini] Kill session requested'); + await handleAbort(); + + try { + session.updateMetadata((currentMetadata) => ({ + ...currentMetadata, + lifecycleState: 'archived', + lifecycleStateSince: Date.now(), + archivedBy: 'cli', + archiveReason: 'User terminated' + })); + + session.sendSessionDeath(); + await session.flush(); + await session.close(); + + stopCaffeinate(); + happyServer.stop(); + + logger.debug('[Gemini] Session termination complete'); + process.exit(0); + } catch (error) { + logger.debug('[Gemini] Error during termination:', error); + process.exit(1); + } + }; + + // Register handlers + session.rpcHandlerManager.registerHandler('abort', handleAbort); + registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); + + // Start Happy MCP server for tool integration + const happyServer = await startHappyServer(session); + logger.debug(`[Gemini] Happy MCP server started at ${happyServer.url}`); + + // Start caffeinate to prevent sleep + startCaffeinate(); + + // Console output + console.log(''); + console.log('🤖 Gemini CLI is ready for remote control!'); + console.log('📱 Use your Happy mobile app to send commands.'); + console.log(''); + if (process.env.DEBUG) { + console.log(`📝 Logs: ${logger.getLogPath()}`); + } + + // Send initial ready event + sendReady(); + + // + // Main loop + // + + try { + while (!shouldExit) { + // Wait for messages from mobile app + const waitSignal = abortController.signal; + const batch = await messageQueue.waitForMessagesAndGetAsString(waitSignal); + + if (!batch) { + if (waitSignal.aborted && !shouldExit) { + logger.debug('[Gemini] Wait aborted while idle'); + continue; + } + break; + } + + const { message, mode } = batch; + logger.debug(`[Gemini] Processing message: ${message.substring(0, 100)}...`); + + // Set thinking state + thinking = true; + session.keepAlive(thinking, 'remote'); + + try { + // Create Gemini query + const query = geminiQuery({ + prompt: message, + options: { + cwd: process.cwd(), + model: mode.model, + abort: abortController.signal, + autoAccept: mode.permissionMode === 'yolo' || mode.permissionMode === 'safe-yolo', + canCallTool: async (toolName, input, { signal }) => { + // Forward permission request to mobile app via session + logger.debug(`[Gemini] Permission request for tool: ${toolName}`); + + // For now, auto-approve based on permission mode + // TODO: Implement proper permission forwarding to mobile app + if (mode.permissionMode === 'yolo') { + return { behavior: 'allow' }; + } + if (mode.permissionMode === 'read-only') { + // Only allow read operations + const readOnlyTools = ['read_file', 'list_files', 'search', 'web_search']; + if (readOnlyTools.some(t => toolName.toLowerCase().includes(t))) { + return { behavior: 'allow' }; + } + return { behavior: 'deny', message: 'Read-only mode: write operations not allowed' }; + } + + // Default: allow + return { behavior: 'allow' }; + } + } + }); + + // Process messages from Gemini + for await (const msg of query) { + await processGeminiMessage(msg, session); + } + + } catch (error) { + const isAbortError = error instanceof Error && error.name === 'AbortError'; + if (isAbortError) { + session.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); + } else { + logger.warn('[Gemini] Error:', error); + session.sendSessionEvent({ + type: 'message', + message: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` + }); + } + } finally { + thinking = false; + session.keepAlive(thinking, 'remote'); + sendReady(); + } + } + + } finally { + // Cleanup + logger.debug('[Gemini] Cleaning up...'); + clearInterval(keepAliveInterval); + + try { + session.sendSessionDeath(); + await session.flush(); + await session.close(); + } catch (e) { + logger.debug('[Gemini] Error closing session:', e); + } + + happyServer.stop(); + stopCaffeinate(); + + logger.debug('[Gemini] Cleanup complete'); + } +} + +/** + * Process a Gemini SDK message and forward to session + */ +async function processGeminiMessage(msg: GeminiSDKMessage, session: any): Promise { + logger.debug(`[Gemini] Message type: ${msg.type}`); + + switch (msg.type) { + case 'init': + session.sendSessionEvent({ + type: 'message', + message: `Session started with model: ${msg.model}` + }); + break; + + case 'message': + if (msg.role === 'assistant') { + // Send assistant message to mobile app + session.sendCodexMessage({ + type: 'message', + message: msg.content, + id: randomUUID() + }); + } + break; + + case 'tool_use': + session.sendCodexMessage({ + type: 'tool-call', + name: msg.tool_name, + callId: msg.tool_id, + input: msg.arguments, + id: randomUUID() + }); + break; + + case 'tool_result': + session.sendCodexMessage({ + type: 'tool-call-result', + callId: msg.tool_id, + output: msg.success ? { output: msg.output } : { error: msg.error }, + id: randomUUID() + }); + break; + + case 'error': + session.sendSessionEvent({ + type: 'message', + message: `Error: ${msg.error}` + }); + break; + + case 'result': + if (msg.statistics) { + session.sendCodexMessage({ + type: 'token_count', + input_tokens: msg.statistics.input_tokens, + output_tokens: msg.statistics.output_tokens, + total_tokens: msg.statistics.total_tokens, + id: randomUUID() + }); + } + break; + + case 'reasoning': + // Send reasoning/thinking to mobile app + session.sendCodexMessage({ + type: 'reasoning', + content: msg.content, + id: randomUUID() + }); + break; + } +} diff --git a/src/gemini/sdk/index.ts b/src/gemini/sdk/index.ts new file mode 100644 index 00000000..7b795d14 --- /dev/null +++ b/src/gemini/sdk/index.ts @@ -0,0 +1,7 @@ +/** + * Gemini SDK exports + */ + +export { geminiQuery, GeminiQuery } from './query'; +export { Stream } from './stream'; +export * from './utils'; diff --git a/src/gemini/sdk/query.ts b/src/gemini/sdk/query.ts new file mode 100644 index 00000000..e8862862 --- /dev/null +++ b/src/gemini/sdk/query.ts @@ -0,0 +1,360 @@ +/** + * Main query implementation for Gemini CLI SDK + * Handles spawning Gemini process and managing message streams + */ + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { Stream } from './stream'; +import { + type GeminiQueryOptions, + type GeminiQueryPrompt, + type GeminiSDKMessage, + type GeminiControlRequest, + type GeminiControlResponse, + type GeminiCanCallToolCallback, + type GeminiPermissionResult, + GeminiAbortError +} from '../types'; +import { getDefaultGeminiPath, getCleanEnv, logDebug, streamToStdin, getPermissionArgs } from './utils'; +import type { Writable } from 'node:stream'; +import { logger } from '@/ui/logger'; + +/** + * Query class manages Gemini CLI process interaction + */ +export class GeminiQuery implements AsyncIterableIterator { + private pendingControlResponses = new Map void>(); + private cancelControllers = new Map(); + private sdkMessages: AsyncIterableIterator; + private inputStream = new Stream(); + private canCallTool?: GeminiCanCallToolCallback; + + constructor( + private childStdin: Writable | null, + private childStdout: NodeJS.ReadableStream, + private processExitPromise: Promise, + canCallTool?: GeminiCanCallToolCallback + ) { + this.canCallTool = canCallTool; + this.readMessages(); + this.sdkMessages = this.readSdkMessages(); + } + + /** + * Set an error on the stream + */ + setError(error: Error): void { + this.inputStream.error(error); + } + + /** + * AsyncIterableIterator implementation + */ + next(...args: [] | [undefined]): Promise> { + return this.sdkMessages.next(...args); + } + + return(value?: any): Promise> { + if (this.sdkMessages.return) { + return this.sdkMessages.return(value); + } + return Promise.resolve({ done: true, value: undefined }); + } + + throw(e: any): Promise> { + if (this.sdkMessages.throw) { + return this.sdkMessages.throw(e); + } + return Promise.reject(e); + } + + [Symbol.asyncIterator](): AsyncIterableIterator { + return this.sdkMessages; + } + + /** + * Read messages from Gemini process stdout + */ + private async readMessages(): Promise { + const rl = createInterface({ input: this.childStdout }); + + try { + for await (const line of rl) { + if (line.trim()) { + try { + const message = JSON.parse(line); + + // Handle control responses + if (message.type === 'control_response') { + const handler = this.pendingControlResponses.get(message.response?.request_id); + if (handler) { + handler(message.response); + } + continue; + } + + // Handle control requests (permission prompts) + if (message.type === 'control_request' || message.type === 'approval_request') { + await this.handleControlRequest(message); + continue; + } + + // Handle control cancel requests + if (message.type === 'control_cancel_request') { + this.handleControlCancelRequest(message); + continue; + } + + // Enqueue regular messages + this.inputStream.enqueue(message as GeminiSDKMessage); + } catch (e) { + // Log unparseable lines for debugging + logger.debug('[gemini] Unparseable line:', line); + } + } + } + await this.processExitPromise; + } catch (error) { + this.inputStream.error(error as Error); + } finally { + this.inputStream.done(); + this.cleanupControllers(); + rl.close(); + } + } + + /** + * Async generator for SDK messages + */ + private async *readSdkMessages(): AsyncIterableIterator { + for await (const message of this.inputStream) { + yield message; + } + } + + /** + * Send interrupt request to Gemini + */ + async interrupt(): Promise { + if (!this.childStdin) { + throw new Error('Interrupt requires stream-json input format'); + } + + const interruptRequest = { + type: 'control_request', + request: { subtype: 'interrupt' } + }; + this.childStdin.write(JSON.stringify(interruptRequest) + '\n'); + } + + /** + * Handle incoming control requests for tool permissions + */ + private async handleControlRequest(request: GeminiControlRequest | any): Promise { + if (!this.childStdin) { + logDebug('Cannot handle control request - no stdin available'); + return; + } + + const requestId = request.request_id || request.request?.request_id; + if (!requestId) { + logDebug('Control request missing request_id'); + return; + } + + const controller = new AbortController(); + this.cancelControllers.set(requestId, controller); + + try { + let response: GeminiPermissionResult; + + if (this.canCallTool) { + const toolName = request.tool_name || request.request?.tool_name; + const input = request.arguments || request.request?.input || {}; + response = await this.canCallTool(toolName, input, { signal: controller.signal }); + } else { + // Default: allow + response = { behavior: 'allow' }; + } + + const controlResponse: GeminiControlResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response + } + }; + this.childStdin.write(JSON.stringify(controlResponse) + '\n'); + } catch (error) { + const controlErrorResponse: GeminiControlResponse = { + type: 'control_response', + response: { + subtype: 'error', + request_id: requestId, + error: error instanceof Error ? error.message : String(error) + } + }; + this.childStdin.write(JSON.stringify(controlErrorResponse) + '\n'); + } finally { + this.cancelControllers.delete(requestId); + } + } + + /** + * Handle control cancel requests + */ + private handleControlCancelRequest(request: any): void { + const requestId = request.request_id; + const controller = this.cancelControllers.get(requestId); + if (controller) { + controller.abort(); + this.cancelControllers.delete(requestId); + } + } + + /** + * Cleanup method to abort all pending control requests + */ + private cleanupControllers(): void { + for (const [requestId, controller] of this.cancelControllers.entries()) { + controller.abort(); + this.cancelControllers.delete(requestId); + } + } +} + +/** + * Main query function to interact with Gemini CLI + */ +export function geminiQuery(config: { + prompt: GeminiQueryPrompt; + options?: GeminiQueryOptions; +}): GeminiQuery { + const { + prompt, + options: { + cwd, + model, + systemPrompt, + includeDirectories, + abort, + pathToGeminiExecutable = getDefaultGeminiPath(), + canCallTool, + sandbox, + autoAccept + } = {} + } = config; + + // Build command arguments + const args: string[] = ['--output-format', 'stream-json']; + + // Add model if specified + if (model) { + args.push('-m', model); + } + + // Add system prompt if specified + if (systemPrompt) { + args.push('--system-prompt', systemPrompt); + } + + // Add include directories + if (includeDirectories && includeDirectories.length > 0) { + args.push('--include-directories', includeDirectories.join(',')); + } + + // Add sandbox mode + if (sandbox) { + args.push('--sandbox', sandbox); + } + + // Add auto-accept for safe tools + if (autoAccept) { + args.push('--auto-accept'); + } + + // Handle prompt input + const isStreamingPrompt = typeof prompt !== 'string'; + + if (typeof prompt === 'string') { + args.push('-p', prompt.trim()); + } else { + args.push('--input-format', 'stream-json'); + } + + // Determine spawn configuration + const isCommandOnly = pathToGeminiExecutable === 'gemini'; + const spawnEnv = isCommandOnly ? getCleanEnv() : process.env; + + logDebug(`Spawning Gemini CLI: ${pathToGeminiExecutable} ${args.join(' ')}`); + + const child = spawn(pathToGeminiExecutable, args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + signal: abort, + env: spawnEnv, + shell: process.platform === 'win32' + }) as ChildProcessWithoutNullStreams; + + // Handle stdin + let childStdin: Writable | null = null; + if (typeof prompt === 'string') { + child.stdin.end(); + } else { + streamToStdin(prompt, child.stdin, abort); + childStdin = child.stdin; + } + + // Handle stderr in debug mode + if (process.env.DEBUG) { + child.stderr.on('data', (data) => { + logger.debug('[gemini] stderr:', data.toString()); + }); + } + + // Setup cleanup + const cleanup = () => { + if (!child.killed) { + child.kill('SIGTERM'); + } + }; + + abort?.addEventListener('abort', cleanup); + process.on('exit', cleanup); + + // Handle process exit + const processExitPromise = new Promise((resolve) => { + child.on('close', (code) => { + if (abort?.aborted) { + query.setError(new GeminiAbortError('Gemini CLI process aborted by user')); + } + if (code !== 0 && code !== null) { + query.setError(new Error(`Gemini CLI process exited with code ${code}`)); + } else { + resolve(); + } + }); + }); + + // Create query instance + const query = new GeminiQuery(childStdin, child.stdout, processExitPromise, canCallTool); + + // Handle process errors + child.on('error', (error) => { + if (abort?.aborted) { + query.setError(new GeminiAbortError('Gemini CLI process aborted by user')); + } else { + query.setError(new Error(`Failed to spawn Gemini CLI process: ${error.message}`)); + } + }); + + // Cleanup on exit + processExitPromise.finally(() => { + cleanup(); + abort?.removeEventListener('abort', cleanup); + }); + + return query; +} diff --git a/src/gemini/sdk/stream.ts b/src/gemini/sdk/stream.ts new file mode 100644 index 00000000..4ea48415 --- /dev/null +++ b/src/gemini/sdk/stream.ts @@ -0,0 +1,84 @@ +/** + * Stream utility for Gemini SDK messages + * Provides async iteration over messages with proper error handling + */ + +export class Stream implements AsyncIterable { + private queue: T[] = []; + private resolvers: Array<{ + resolve: (result: IteratorResult) => void; + reject: (error: Error) => void; + }> = []; + private isDone = false; + private errorValue: Error | null = null; + + /** + * Add a message to the stream + */ + enqueue(item: T): void { + if (this.isDone) { + return; + } + + if (this.resolvers.length > 0) { + const resolver = this.resolvers.shift()!; + resolver.resolve({ value: item, done: false }); + } else { + this.queue.push(item); + } + } + + /** + * Mark the stream as complete + */ + done(): void { + this.isDone = true; + for (const resolver of this.resolvers) { + resolver.resolve({ value: undefined as any, done: true }); + } + this.resolvers = []; + } + + /** + * Set an error on the stream + */ + error(err: Error): void { + this.errorValue = err; + this.isDone = true; + for (const resolver of this.resolvers) { + resolver.reject(err); + } + this.resolvers = []; + } + + /** + * Async iterator implementation + */ + async *[Symbol.asyncIterator](): AsyncGenerator { + while (true) { + if (this.errorValue) { + throw this.errorValue; + } + + if (this.queue.length > 0) { + yield this.queue.shift()!; + continue; + } + + if (this.isDone) { + return; + } + + // Wait for next item + const item = await new Promise>((resolve, reject) => { + this.resolvers.push({ resolve, reject }); + }); + + if (item.done) { + return; + } + + yield item.value; + } + } +} diff --git a/src/gemini/sdk/utils.ts b/src/gemini/sdk/utils.ts new file mode 100644 index 00000000..5f28b2c7 --- /dev/null +++ b/src/gemini/sdk/utils.ts @@ -0,0 +1,140 @@ +/** + * Utility functions for Gemini SDK + */ + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { logger } from '@/ui/logger'; + +/** + * Get the default path to Gemini CLI executable + */ +export function getDefaultGeminiPath(): string { + // First, try to find gemini in common locations + const possiblePaths = [ + // Global npm installation + join(homedir(), '.npm', 'bin', 'gemini'), + // Homebrew on macOS + '/opt/homebrew/bin/gemini', + '/usr/local/bin/gemini', + // Linux global + '/usr/bin/gemini', + ]; + + for (const p of possiblePaths) { + if (existsSync(p)) { + return p; + } + } + + // Default to just 'gemini' and let the system find it + return 'gemini'; +} + +/** + * Get clean environment for spawning Gemini + * Removes node_modules/.bin from PATH to avoid conflicts + */ +export function getCleanEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + + if (env.PATH) { + // Remove local node_modules/.bin paths + const pathSeparator = process.platform === 'win32' ? ';' : ':'; + const paths = env.PATH.split(pathSeparator); + const cleanPaths = paths.filter(p => !p.includes('node_modules/.bin')); + env.PATH = cleanPaths.join(pathSeparator); + } + + return env; +} + +/** + * Log debug message if DEBUG env is set + */ +export function logDebug(message: string, ...args: unknown[]): void { + if (process.env.DEBUG) { + logger.debug(`[gemini-sdk] ${message}`, ...args); + } +} + +/** + * Stream prompt messages to stdin + */ +export async function streamToStdin( + prompt: AsyncIterable<{ type: string; content: string }>, + stdin: NodeJS.WritableStream, + abort?: AbortSignal +): Promise { + try { + for await (const message of prompt) { + if (abort?.aborted) { + break; + } + stdin.write(JSON.stringify(message) + '\n'); + } + } catch (error) { + logDebug('Error streaming to stdin:', error); + } finally { + stdin.end(); + } +} + +/** + * Convert Gemini permission mode to CLI arguments + */ +export function getPermissionArgs(mode: string): string[] { + switch (mode) { + case 'yolo': + return ['--yolo']; // Auto-accept all tool calls + case 'safe-yolo': + return ['--auto-accept']; // Auto-accept safe tool calls + case 'read-only': + return ['--sandbox', 'read-only']; + case 'default': + default: + return []; + } +} + +/** + * Parse Gemini CLI version from output + */ +export function parseGeminiVersion(output: string): string | null { + const match = output.match(/gemini[- ]cli[- ]v?(\d+\.\d+\.\d+)/i); + return match ? match[1] : null; +} + +/** + * Check if Gemini CLI is installed + */ +export async function isGeminiInstalled(): Promise { + const { spawn } = await import('node:child_process'); + + return new Promise((resolve) => { + const child = spawn('gemini', ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32' + }); + + let output = ''; + child.stdout?.on('data', (data) => { + output += data.toString(); + }); + + child.on('close', (code) => { + resolve(code === 0); + }); + + child.on('error', () => { + resolve(false); + }); + + // Timeout after 5 seconds + setTimeout(() => { + child.kill(); + resolve(false); + }, 5000); + }); +} diff --git a/src/gemini/types.ts b/src/gemini/types.ts new file mode 100644 index 00000000..23fd07c4 --- /dev/null +++ b/src/gemini/types.ts @@ -0,0 +1,241 @@ +/** + * Type definitions for Gemini CLI integration + * Based on the stream-json output format from Gemini CLI + */ + +// ==================== +// Gemini Message Types +// ==================== + +/** + * Init event - signifies the start of a session + */ +export interface GeminiInitMessage { + type: 'init'; + session_id: string; + model: string; + timestamp?: number; +} + +/** + * Message event - user prompts and assistant responses + */ +export interface GeminiTextMessage { + type: 'message'; + role: 'user' | 'assistant'; + content: string; + timestamp?: number; +} + +/** + * Tool use event - tool call requests with parameters + */ +export interface GeminiToolUseMessage { + type: 'tool_use'; + tool_name: string; + tool_id: string; + arguments: Record; + timestamp?: number; +} + +/** + * Tool result event - outcome of tool execution + */ +export interface GeminiToolResultMessage { + type: 'tool_result'; + tool_id: string; + success: boolean; + output?: string; + error?: string; + timestamp?: number; +} + +/** + * Error event - non-fatal errors and warnings + */ +export interface GeminiErrorMessage { + type: 'error'; + error: string; + code?: string; + timestamp?: number; +} + +/** + * Result event - final session outcome with statistics + */ +export interface GeminiResultMessage { + type: 'result'; + success: boolean; + response?: string; + statistics?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + duration_ms?: number; + }; + timestamp?: number; +} + +/** + * Reasoning/thinking event (if available) + */ +export interface GeminiReasoningMessage { + type: 'reasoning'; + content: string; + timestamp?: number; +} + +/** + * Permission/approval request for tool execution + */ +export interface GeminiApprovalRequest { + type: 'approval_request'; + request_id: string; + tool_name: string; + arguments: Record; + description?: string; + timestamp?: number; +} + +/** + * Union type for all Gemini SDK messages + */ +export type GeminiSDKMessage = + | GeminiInitMessage + | GeminiTextMessage + | GeminiToolUseMessage + | GeminiToolResultMessage + | GeminiErrorMessage + | GeminiResultMessage + | GeminiReasoningMessage + | GeminiApprovalRequest; + +// ==================== +// Control Request/Response Types +// ==================== + +/** + * Control request for tool approval + */ +export interface GeminiControlRequest { + type: 'control_request'; + request_id: string; + request: { + subtype: 'can_use_tool'; + tool_name: string; + input: Record; + }; +} + +/** + * Control response for tool approval + */ +export interface GeminiControlResponse { + type: 'control_response'; + response: { + subtype: 'success' | 'error'; + request_id: string; + response?: { + behavior: 'allow' | 'deny'; + message?: string; + }; + error?: string; + }; +} + +/** + * Permission result type + */ +export interface GeminiPermissionResult { + behavior: 'allow' | 'deny'; + message?: string; +} + +/** + * Callback type for tool permission handling + */ +export type GeminiCanCallToolCallback = ( + toolName: string, + input: Record, + options: { signal?: AbortSignal } +) => Promise; + +// ==================== +// Query Options +// ==================== + +/** + * Options for Gemini query + */ +export interface GeminiQueryOptions { + /** Working directory */ + cwd?: string; + /** Model to use (e.g., 'gemini-2.5-pro', 'gemini-2.5-flash') */ + model?: string; + /** Custom system prompt */ + systemPrompt?: string; + /** Additional directories to include */ + includeDirectories?: string[]; + /** Abort signal */ + abort?: AbortSignal; + /** Path to Gemini CLI executable */ + pathToGeminiExecutable?: string; + /** Tool permission callback */ + canCallTool?: GeminiCanCallToolCallback; + /** Enable sandbox mode */ + sandbox?: 'read-only' | 'workspace-write' | 'full-access'; + /** Auto-accept safe tool calls */ + autoAccept?: boolean; +} + +/** + * Query prompt type - can be a string or async iterable for streaming + */ +export type GeminiQueryPrompt = string | AsyncIterable<{ type: 'user_message'; content: string }>; + +// ==================== +// Session Configuration +// ==================== + +/** + * Gemini session configuration + */ +export interface GeminiSessionConfig { + prompt: string; + sandbox?: 'read-only' | 'workspace-write' | 'full-access'; + model?: string; + config?: { + mcp_servers?: Record; + }; +} + +// ==================== +// Permission Modes +// ==================== + +export type GeminiPermissionMode = 'default' | 'read-only' | 'safe-yolo' | 'yolo'; + +/** + * Enhanced mode with permission and model settings + */ +export interface GeminiEnhancedMode { + permissionMode: GeminiPermissionMode; + model?: string; +} + +// ==================== +// Error Types +// ==================== + +/** + * Custom abort error + */ +export class GeminiAbortError extends Error { + constructor(message: string) { + super(message); + this.name = 'AbortError'; + } +} diff --git a/src/index.ts b/src/index.ts index 72febfa8..05c6311c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,7 +81,7 @@ import { execFileSync } from 'node:child_process' // Handle codex command try { const { runCodex } = await import('@/codex/runCodex'); - + // Parse startedBy argument let startedBy: 'daemon' | 'terminal' | undefined = undefined; for (let i = 1; i < args.length; i++) { @@ -89,11 +89,11 @@ import { execFileSync } from 'node:child_process' startedBy = args[++i] as 'daemon' | 'terminal'; } } - + const { credentials } = await authAndSetupMachineIfNeeded(); - await runCodex({credentials, startedBy}); + await runCodex({ credentials, startedBy }); // Do not force exit here; allow instrumentation to show lingering handles } catch (error) { console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') @@ -103,6 +103,35 @@ import { execFileSync } from 'node:child_process' process.exit(1) } return; + } else if (subcommand === 'gemini') { + // Handle gemini command + try { + const { runGemini } = await import('@/gemini/runGemini'); + + // Parse arguments + let startedBy: 'daemon' | 'terminal' | undefined = undefined; + let model: string | undefined = undefined; + + for (let i = 1; i < args.length; i++) { + if (args[i] === '--started-by') { + startedBy = args[++i] as 'daemon' | 'terminal'; + } else if (args[i] === '-m' || args[i] === '--model') { + model = args[++i]; + } + } + + const { + credentials + } = await authAndSetupMachineIfNeeded(); + await runGemini({ credentials, startedBy, model }); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + return; } else if (subcommand === 'logout') { // Keep for backward compatibility - redirect to auth logout console.log(chalk.yellow('Note: "happy logout" is deprecated. Use "happy auth logout" instead.\n')); @@ -302,12 +331,13 @@ ${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('happy doctor c // Show help if (showHelp) { console.log(` -${chalk.bold('happy')} - Claude Code On the Go +${chalk.bold('happy')} - AI Coding Assistants On the Go ${chalk.bold('Usage:')} happy [options] Start Claude with mobile control happy auth Manage authentication - happy codex Start Codex mode + happy codex Start Codex mode (OpenAI) + happy gemini Start Gemini mode (Google) happy connect Connect AI vendor API keys happy notify Send push notification happy daemon Manage background service that allows @@ -315,7 +345,11 @@ ${chalk.bold('Usage:')} happy doctor System diagnostics & troubleshooting ${chalk.bold('Examples:')} - happy Start session + happy Start Claude session + happy codex Start OpenAI Codex session + happy gemini Start Google Gemini session + happy gemini -m gemini-2.5-flash + Start Gemini with specific model happy --yolo Start with bypassing permissions happy sugar for --dangerously-skip-permissions happy --claude-env ANTHROPIC_BASE_URL=http://127.0.0.1:3456 @@ -329,9 +363,8 @@ ${chalk.bold('Happy supports ALL Claude options!')} happy --resume ${chalk.gray('─'.repeat(60))} -${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} -`) - +${chalk.bold.cyan('Claude Code Options (from `claude --help`):')}`) + // Run claude --help and display its output // Use execFileSync with the current Node executable for cross-platform compatibility try { @@ -340,7 +373,7 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} } catch (e) { console.log(chalk.yellow('Could not retrieve claude help. Make sure claude is installed.')) } - + process.exit(0) } From 6ba1e9f86ffeba5276247fb37da42c82b397ddab Mon Sep 17 00:00:00 2001 From: Penny777 Date: Wed, 17 Dec 2025 13:35:13 +1300 Subject: [PATCH 2/2] feat: add notify_user tool to MCP server for remote notifications --- src/claude/utils/startHappyServer.ts | 37 +++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/claude/utils/startHappyServer.ts b/src/claude/utils/startHappyServer.ts index 9a1bb21b..207b8de8 100644 --- a/src/claude/utils/startHappyServer.ts +++ b/src/claude/utils/startHappyServer.ts @@ -23,7 +23,7 @@ export async function startHappyServer(client: ApiSessionClient) { summary: title, leafUuid: randomUUID() }); - + return { success: true }; } catch (error) { return { success: false, error: String(error) }; @@ -48,7 +48,7 @@ export async function startHappyServer(client: ApiSessionClient) { }, async (args) => { const response = await handler(args.title); logger.debug('[happyMCP] Response:', response); - + if (response.success) { return { content: [ @@ -72,6 +72,34 @@ export async function startHappyServer(client: ApiSessionClient) { } }); + // Add notify_user tool + mcp.registerTool('notify_user', { + description: 'Send a notification message to the user on their mobile device', + title: 'Notify User', + inputSchema: { + message: z.string().describe('The message to send to the user'), + level: z.enum(['info', 'warning', 'error']).optional().describe('Notification level'), + }, + }, async (args) => { + try { + // Send as a session event/message + client.sendSessionEvent({ + type: 'message', + message: `📱 [IDE Notification] ${args.level ? `[${args.level.toUpperCase()}] ` : ''}${args.message}` + }); + + return { + content: [{ type: 'text', text: `Notification sent to user device: "${args.message}"` }], + isError: false, + }; + } catch (error) { + return { + content: [{ type: 'text', text: `Failed to send notification: ${String(error)}` }], + isError: true, + }; + } + }); + const transport = new StreamableHTTPServerTransport({ // NOTE: Returning session id here will result in claude // sdk spawn to fail with `Invalid Request: Server already initialized` @@ -101,9 +129,12 @@ export async function startHappyServer(client: ApiSessionClient) { }); }); + // Write URL to a file so other tools can find it easily if needed, or just log it + // For now, we rely on the main process logging it + return { url: baseUrl.toString(), - toolNames: ['change_title'], + toolNames: ['change_title', 'notify_user'], stop: () => { logger.debug('[happyMCP] Stopping server'); mcp.close();