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
29 changes: 28 additions & 1 deletion packages/happy-app/sources/components/modelModeOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ const GEMINI_MODEL_FALLBACKS: ModelMode[] = [
{ key: 'gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash Lite', description: 'Fastest' },
];

const KIMI_MODEL_FALLBACKS: ModelMode[] = [
{ key: 'kimi-k2.5', name: 'Kimi K2.5', description: 'Most capable' },
{ key: 'kimi-k2-thinking', name: 'Kimi K2 Thinking', description: 'Deep reasoning' },
{ key: 'kimi-k2-thinking-turbo', name: 'Kimi K2 Thinking Turbo', description: 'Fast reasoning' },
];

export function mapMetadataOptions(options?: MetadataOption[] | null): ModeOption[] {
if (!options || options.length === 0) {
return [];
Expand Down Expand Up @@ -93,13 +99,28 @@ export function getGeminiModelModes(): ModelMode[] {
return GEMINI_MODEL_FALLBACKS;
}

export function getKimiPermissionModes(translate: Translate): PermissionMode[] {
// Kimi uses the same permission model as Gemini (default / read-only / yolo)
return [
{ key: 'default', name: translate('agentInput.geminiPermissionMode.default'), description: null },
{ key: 'yolo', name: translate('agentInput.geminiPermissionMode.yolo'), description: null },
];
}

export function getKimiModelModes(): ModelMode[] {
return KIMI_MODEL_FALLBACKS;
}

export function getHardcodedPermissionModes(flavor: AgentFlavor, translate: Translate): PermissionMode[] {
if (flavor === 'codex') {
return getCodexPermissionModes(translate);
}
if (flavor === 'gemini') {
return getGeminiPermissionModes(translate);
}
if (flavor === 'kimi') {
return getKimiPermissionModes(translate);
}
return getClaudePermissionModes(translate);
}

Expand All @@ -110,6 +131,9 @@ export function getHardcodedModelModes(flavor: AgentFlavor, translate: Translate
if (flavor === 'gemini') {
return getGeminiModelModes();
}
if (flavor === 'kimi') {
return getKimiModelModes();
}
return getClaudeModelModes();
}

Expand All @@ -130,7 +154,7 @@ export function getAvailablePermissionModes(
metadata: Metadata | null | undefined,
translate: Translate,
): PermissionMode[] {
if (flavor === 'claude' || flavor === 'codex') {
if (flavor === 'claude' || flavor === 'codex' || flavor === 'kimi') {
return hackModes(getHardcodedPermissionModes(flavor, translate));
}

Expand Down Expand Up @@ -169,6 +193,9 @@ export function getDefaultModelKey(flavor: AgentFlavor): string {
if (flavor === 'gemini') {
return 'gemini-2.5-pro';
}
if (flavor === 'kimi') {
return 'kimi-k2.5';
}
return 'default';
}

Expand Down
5 changes: 4 additions & 1 deletion packages/happy-cli/src/agent/acp/runAcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,10 +435,13 @@ type PendingTurn = {
timeout: NodeJS.Timeout;
};

function resolveSessionFlavor(agentName: string): 'gemini' | 'opencode' | 'acp' {
function resolveSessionFlavor(agentName: string): 'gemini' | 'kimi' | 'opencode' | 'acp' {
if (agentName === 'gemini') {
return 'gemini';
}
if (agentName === 'kimi') {
return 'kimi';
}
if (agentName === 'opencode') {
return 'opencode';
}
Expand Down
4 changes: 2 additions & 2 deletions packages/happy-cli/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ export class ApiClient {
* Register a vendor API token with the server
* The token is sent as a JSON string - server handles encryption
*/
async registerVendorToken(vendor: 'openai' | 'anthropic' | 'gemini', apiKey: any): Promise<void> {
async registerVendorToken(vendor: 'openai' | 'anthropic' | 'gemini' | 'moonshot', apiKey: any): Promise<void> {
try {
const response = await axios.post(
`${configuration.serverUrl}/v1/connect/${vendor}/register`,
Expand Down Expand Up @@ -320,7 +320,7 @@ export class ApiClient {
* Get vendor API token from the server
* Returns the token if it exists, null otherwise
*/
async getVendorToken(vendor: 'openai' | 'anthropic' | 'gemini'): Promise<any | null> {
async getVendorToken(vendor: 'openai' | 'anthropic' | 'gemini' | 'moonshot'): Promise<any | null> {
try {
const response = await axios.get(
`${configuration.serverUrl}/v1/connect/${vendor}/token`,
Expand Down
57 changes: 53 additions & 4 deletions packages/happy-cli/src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ApiClient } from '@/api/api';
import { authenticateCodex } from './connect/authenticateCodex';
import { authenticateClaude } from './connect/authenticateClaude';
import { authenticateGemini } from './connect/authenticateGemini';
import { authenticateKimi } from './connect/authenticateKimi';
import { decodeJwtPayload } from './connect/utils';

/**
Expand Down Expand Up @@ -36,6 +37,9 @@ export async function handleConnectCommand(args: string[]): Promise<void> {
case 'gemini':
await handleConnectVendor('gemini', 'Gemini');
break;
case 'kimi':
await handleConnectVendor('kimi', 'Kimi');
break;
case 'status':
await handleConnectStatus();
break;
Expand All @@ -54,6 +58,7 @@ ${chalk.bold('Usage:')}
happy connect codex Store your Codex API key in Happy cloud
happy connect claude Store your Anthropic API key in Happy cloud
happy connect gemini Store your Gemini API key in Happy cloud
happy connect kimi Store your Moonshot API key in Happy cloud
happy connect status Show connection status for all vendors
happy connect help Show this help message

Expand All @@ -66,6 +71,7 @@ ${chalk.bold('Examples:')}
happy connect codex
happy connect claude
happy connect gemini
happy connect kimi
happy connect status

${chalk.bold('Notes:')}
Expand All @@ -75,7 +81,7 @@ ${chalk.bold('Notes:')}
`);
}

async function handleConnectVendor(vendor: 'codex' | 'claude' | 'gemini', displayName: string): Promise<void> {
async function handleConnectVendor(vendor: 'codex' | 'claude' | 'gemini' | 'kimi', displayName: string): Promise<void> {
console.log(chalk.bold(`\n🔌 Connecting ${displayName} to Happy cloud\n`));

// Check if authenticated
Expand Down Expand Up @@ -107,10 +113,20 @@ async function handleConnectVendor(vendor: 'codex' | 'claude' | 'gemini', displa
const geminiAuthTokens = await authenticateGemini();
await api.registerVendorToken('gemini', { oauth: geminiAuthTokens });
console.log('✅ Gemini token registered with server');

// Also update local Gemini config to keep tokens in sync
updateLocalGeminiCredentials(geminiAuthTokens);


process.exit(0);
} else if (vendor === 'kimi') {
console.log('🚀 Registering Kimi/Moonshot token with server');
const kimiAuthTokens = await authenticateKimi();
await api.registerVendorToken('moonshot', { oauth: kimiAuthTokens });
console.log('✅ Kimi token registered with server');

// Also save API key locally so kimi CLI can pick it up
updateLocalKimiCredentials(kimiAuthTokens.access_token);

process.exit(0);
} else {
throw new Error(`Unsupported vendor: ${vendor}`);
Expand All @@ -135,10 +151,11 @@ async function handleConnectStatus(): Promise<void> {
const api = await ApiClient.create(credentials);

// Check each vendor
const vendors: Array<{ key: 'openai' | 'anthropic' | 'gemini'; name: string; display: string }> = [
const vendors: Array<{ key: 'openai' | 'anthropic' | 'gemini' | 'moonshot'; name: string; display: string }> = [
{ key: 'gemini', name: 'Gemini', display: 'Google Gemini' },
{ key: 'openai', name: 'Codex', display: 'OpenAI Codex' },
{ key: 'anthropic', name: 'Claude', display: 'Anthropic Claude' },
{ key: 'moonshot', name: 'Kimi', display: 'Moonshot Kimi' },
];

for (const vendor of vendors) {
Expand Down Expand Up @@ -216,4 +233,36 @@ function updateLocalGeminiCredentials(tokens: {
// Non-critical error - server tokens will still work
console.log(chalk.yellow(` ⚠️ Could not update local credentials: ${error}`));
}
}

/**
* Cache Moonshot API key locally so Happy can pass it to kimi-cli via
* MOONSHOT_API_KEY env var on next session start. This is Happy's own
* credential store — kimi-cli itself uses ~/.kimi/config.toml.
*/
function updateLocalKimiCredentials(apiKey: string): void {
try {
const kimiDir = join(homedir(), '.kimi');
const configPath = join(kimiDir, 'config.json');

if (!existsSync(kimiDir)) {
mkdirSync(kimiDir, { recursive: true });
}

// Read existing config or start fresh
let config: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = JSON.parse(require('fs').readFileSync(configPath, 'utf-8'));
} catch {
// corrupt file — overwrite
}
}

config.api_key = apiKey;
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
console.log(chalk.gray(` Updated local credentials: ${configPath}`));
} catch (error) {
console.log(chalk.yellow(` ⚠️ Could not update local credentials: ${error}`));
}
}
62 changes: 62 additions & 0 deletions packages/happy-cli/src/commands/connect/authenticateKimi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Kimi/Moonshot authentication helper
*
* Kimi CLI authenticates via MOONSHOT_API_KEY. This module prompts the user
* for their API key and returns it in a token-compatible format so it can
* be stored in Happy cloud alongside other vendor tokens.
*/

import * as readline from 'readline';

export interface KimiAuthTokens {
access_token: string;
token_type: string;
}

/**
* Prompt the user for their Moonshot API key.
*
* Kimi/Moonshot doesn't use OAuth — the CLI reads MOONSHOT_API_KEY
* from the environment. We wrap it in the same token shape used by other
* vendors so the Happy cloud can store and relay it uniformly.
*/
export async function authenticateKimi(): Promise<KimiAuthTokens> {
console.log('🔑 Kimi uses a Moonshot API key for authentication.');
console.log(' Get your key from: https://platform.moonshot.cn/console/api-keys');
console.log('');

const apiKey = await promptForInput('Enter your Moonshot API key: ');

if (!apiKey || !apiKey.trim()) {
throw new Error('No API key provided');
}

const trimmed = apiKey.trim();

// Basic format check — Moonshot keys start with "sk-"
if (!trimmed.startsWith('sk-')) {
console.log('⚠️ Warning: Moonshot API keys usually start with "sk-".');
console.log(' Continuing anyway — double-check the key if authentication fails.');
}

console.log('');
console.log('🎉 API key received!');

return {
access_token: trimmed,
token_type: 'Bearer',
};
}

function promptForInput(question: string): Promise<string> {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
5 changes: 5 additions & 0 deletions packages/happy-cli/src/commands/connect/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export interface PKCECodes {
challenge: string;
}

export interface KimiAuthTokens {
access_token: string;
token_type: string;
}

export interface ClaudeAuthTokens {
raw: any;
token: string;
Expand Down
13 changes: 9 additions & 4 deletions packages/happy-cli/src/daemon/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const initialMachineMetadata: MachineMetadata = {
// Get environment variables for a profile, filtered for agent compatibility
async function getProfileEnvironmentVariablesForAgent(
profileId: string,
agentType: 'claude' | 'codex' | 'gemini'
agentType: 'claude' | 'codex' | 'gemini' | 'kimi'
): Promise<Record<string, string>> {
try {
const settings = await readSettings();
Expand Down Expand Up @@ -285,6 +285,8 @@ export async function startDaemon(): Promise<void> {

// Set the environment variable for Codex
authEnv.CODEX_HOME = codexHomeDir.name;
} else if (options.agent === 'kimi') {
authEnv.MOONSHOT_API_KEY = options.token;
} else { // Assuming claude
authEnv.CLAUDE_CODE_OAUTH_TOKEN = options.token;
}
Expand Down Expand Up @@ -386,8 +388,8 @@ export async function startDaemon(): Promise<void> {

// Construct command for the CLI
const cliPath = join(projectPath(), 'dist', 'index.mjs');
// Determine agent command - support claude, codex, and gemini
const agent = options.agent === 'gemini' ? 'gemini' : (options.agent === 'codex' ? 'codex' : 'claude');
// Determine agent command - support claude, codex, gemini, and kimi
const agent = options.agent === 'gemini' ? 'gemini' : (options.agent === 'kimi' ? 'kimi' : (options.agent === 'codex' ? 'codex' : 'claude'));
const fullCommand = `node --no-warnings --no-deprecation ${cliPath} ${agent} --happy-starting-mode remote --started-by daemon`;

// Spawn in tmux with environment variables
Expand Down Expand Up @@ -470,7 +472,7 @@ export async function startDaemon(): Promise<void> {
if (!useTmux) {
logger.debug(`[DAEMON RUN] Using regular process spawning`);

// Construct arguments for the CLI - support claude, codex, and gemini
// Construct arguments for the CLI - support claude, codex, gemini, and kimi
let agentCommand: string;
switch (options.agent) {
case 'claude':
Expand All @@ -483,6 +485,9 @@ export async function startDaemon(): Promise<void> {
case 'gemini':
agentCommand = 'gemini';
break;
case 'kimi':
agentCommand = 'kimi';
break;
default:
return {
type: 'error',
Expand Down
Loading