Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,84 @@ import { execFileSync } from 'node:child_process'
process.exit(1)
}
return;
} else if (subcommand === 'opencode') {
try {
const { runOpenCode } = await import('@/opencode/runOpenCode');

let startedBy: 'daemon' | 'terminal' | undefined = undefined;
let model: string | undefined = undefined;
let provider: string | undefined = undefined;
let permissionMode: 'default' | 'acceptEdits' | 'bypassPermissions' = 'default';
let baseUrl: 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] === '--model' || args[i] === '-m') {
model = args[++i];
} else if (args[i] === '--provider' || args[i] === '-p') {
provider = args[++i];
} else if (args[i] === '--yolo' || args[i] === '--dangerously-skip-permissions') {
permissionMode = 'bypassPermissions';
} else if (args[i] === '--accept-edits') {
permissionMode = 'acceptEdits';
} else if (args[i] === '--base-url') {
baseUrl = args[++i];
} else if (args[i] === '-h' || args[i] === '--help') {
console.log(`
${chalk.bold('happy opencode')} - OpenCode integration

${chalk.bold('Usage:')}
happy opencode [options]

${chalk.bold('Options:')}
-m, --model <model> Model to use (e.g., claude-3-5-sonnet)
-p, --provider <provider> Provider ID (e.g., anthropic, openrouter)
--yolo Bypass all permissions
--accept-edits Auto-accept file edit permissions
--base-url <url> OpenCode API URL (default: http://localhost:4096)
-h, --help Show this help

${chalk.bold('Examples:')}
happy opencode Start with default settings
happy opencode --yolo Start with all permissions bypassed
happy opencode -m gpt-4o Use specific model

${chalk.bold('Note:')} OpenCode must be running locally (opencode --server)
`);
process.exit(0);
}
}

const { credentials } = await authAndSetupMachineIfNeeded();

logger.debug('Ensuring Happy background service is running & matches our version...');
if (!(await isDaemonRunningCurrentlyInstalledHappyVersion())) {
logger.debug('Starting Happy background service...');
const daemonProcess = spawnHappyCLI(['daemon', 'start-sync'], {
detached: true,
stdio: 'ignore',
env: process.env
});
daemonProcess.unref();
await new Promise(resolve => setTimeout(resolve, 200));
}

await runOpenCode(credentials, {
model,
provider,
permissionMode,
startedBy,
baseUrl
});
} 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'));
Expand Down Expand Up @@ -444,6 +522,7 @@ ${chalk.bold('Usage:')}
happy auth Manage authentication
happy codex Start Codex mode
happy gemini Start Gemini mode (ACP)
happy opencode Start OpenCode mode (HTTP API)
happy connect Connect AI vendor API keys
happy notify Send push notification
happy daemon Manage background service that allows
Expand Down
5 changes: 5 additions & 0 deletions src/opencode/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { runOpenCode } from './runOpenCode';
export { OpenCodeClient } from './openCodeClient';
export { OpenCodePermissionHandler } from './utils/permissionHandler';
export * from './types';
export * from './messageMapper';
159 changes: 159 additions & 0 deletions src/opencode/messageMapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { randomUUID } from 'node:crypto';
import type {
OpenCodeMessageInfo,
OpenCodeMessagePart,
OpenCodeMessage,
OpenCodeTodo
} from './types';

export interface HappyMessage {
type: string;
id: string;
[key: string]: unknown;
}

export interface HappyToolCall extends HappyMessage {
type: 'tool-call';
name: string;
callId: string;
input: Record<string, unknown>;
}

export interface HappyToolResult extends HappyMessage {
type: 'tool-call-result';
callId: string;
output: unknown;
}

export interface HappyTextMessage extends HappyMessage {
type: 'message';
message: string;
}

export interface HappyReasoningMessage extends HappyMessage {
type: 'reasoning';
text: string;
}

export interface HappyTodoMessage extends HappyMessage {
type: 'todo';
todos: Array<{
id: string;
content: string;
status: string;
priority?: string;
}>;
}

export function mapOpenCodePartToHappyMessage(part: OpenCodeMessagePart): HappyMessage | null {
switch (part.type) {
case 'text':
if (!part.text) return null;
return {
type: 'message',
id: part.id,
message: part.text
} as HappyTextMessage;

case 'tool-invocation':
if (!part.toolInvocation) return null;
const inv = part.toolInvocation;

if (inv.state === 'pending' || inv.state === 'running') {
return {
type: 'tool-call',
id: part.id,
name: inv.toolName,
callId: inv.toolCallID,
input: inv.args || {},
metadata: inv.metadata
} as HappyToolCall;
}

if (inv.state === 'completed' || inv.state === 'failed') {
return {
type: 'tool-call-result',
id: part.id,
callId: inv.toolCallID,
output: inv.error ? { error: inv.error } : inv.result,
success: inv.state === 'completed'
} as HappyToolResult;
}
return null;

case 'reasoning':
if (!part.text) return null;
return {
type: 'reasoning',
id: part.id,
text: part.text
} as HappyReasoningMessage;

case 'step-start':
return {
type: 'step-start',
id: part.id,
text: part.text || ''
};

case 'file':
if (!part.file) return null;
return {
type: 'file',
id: part.id,
path: part.file.path,
content: part.file.content
};

default:
return null;
}
}

export function mapOpenCodeTodosToHappyMessage(todos: OpenCodeTodo[]): HappyTodoMessage {
return {
type: 'todo',
id: randomUUID(),
todos: todos.map(t => ({
id: t.id,
content: t.content,
status: t.status,
priority: t.priority
}))
};
}

export function mapOpenCodeMessageInfoToStatus(info: OpenCodeMessageInfo): HappyMessage {
const hasError = !!info.error;
const isComplete = !!info.time.completed;

return {
type: 'message-status',
id: info.id,
messageId: info.id,
sessionId: info.sessionID,
role: info.role,
status: hasError ? 'error' : (isComplete ? 'complete' : 'in-progress'),
error: info.error,
tokens: info.tokens,
cost: info.cost,
model: info.modelID,
provider: info.providerID
};
}

export function createHappyEventFromOpenCodePart(
part: OpenCodeMessagePart,
info?: OpenCodeMessageInfo
): HappyMessage | null {
const message = mapOpenCodePartToHappyMessage(part);
if (!message) return null;

if (info) {
message.role = info.role;
message.model = info.modelID;
message.provider = info.providerID;
}

return message;
}
Loading