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
177 changes: 177 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,182 @@ import { execFileSync } from 'node:child_process'
process.exit(1)
}
return;
} else if (subcommand === 'zai') {
// Handle zai subcommands
const zaiSubcommand = args[1];

// Handle "happy zai token set <token>" command
if (zaiSubcommand === 'token' && args[2] === 'set' && args[3]) {
const token = args[3];
try {
const { writeZaiConfig, readZaiConfig } = await import('@/zai/runZai');

// Read existing config or create new one
const existingConfig = readZaiConfig();
const updatedConfig = {
...existingConfig,
authToken: token
};

writeZaiConfig(updatedConfig);
console.log('✓ Z.AI API token saved to ~/.zai/config.json');
console.log(' You can now run: happy zai');
process.exit(0);
} catch (error) {
console.error('Failed to save token:', error);
process.exit(1);
}
}

// Handle "happy zai token get" command
if (zaiSubcommand === 'token' && args[2] === 'get') {
try {
const { readZaiConfig } = await import('@/zai/runZai');
const config = readZaiConfig();

if (config.authToken) {
console.log(`Token: ${config.authToken.substring(0, 10)}...${config.authToken.substring(Math.max(0, config.authToken.length - 4))}`);
} else if (process.env.ZAI_AUTH_TOKEN) {
console.log(`Token (from env): ${process.env.ZAI_AUTH_TOKEN.substring(0, 10)}...${process.env.ZAI_AUTH_TOKEN.substring(Math.max(0, process.env.ZAI_AUTH_TOKEN.length - 4))}`);
} else {
console.log('No token configured. Set one with: happy zai token set <your-token>');
}
process.exit(0);
} catch (error) {
console.error('Failed to read token:', error);
process.exit(1);
}
}

// Handle "happy zai model set <model>" command
if (zaiSubcommand === 'model' && args[2] === 'set' && args[3]) {
const modelName = args[3];
try {
const { writeZaiConfig, readZaiConfig, isValidZaiModel, VALID_ZAI_MODELS } = await import('@/zai/runZai');

if (!isValidZaiModel(modelName)) {
console.error(`Invalid model: ${modelName}`);
console.error(`Available models: ${VALID_ZAI_MODELS.join(', ')}`);
process.exit(1);
}

// Read existing config or create new one
const existingConfig = readZaiConfig();
const updatedConfig = {
...existingConfig,
model: modelName
};

writeZaiConfig(updatedConfig);
console.log(`✓ Model set to: ${modelName}`);
console.log(' This model will be used in future sessions.');
process.exit(0);
} catch (error) {
console.error('Failed to save model configuration:', error);
process.exit(1);
}
}

// Handle "happy zai model get" command
if (zaiSubcommand === 'model' && args[2] === 'get') {
try {
const { readZaiConfig, DEFAULT_ZAI_MODEL } = await import('@/zai/runZai');
const config = readZaiConfig();

if (config.model) {
console.log(`Current model: ${config.model}`);
} else if (process.env.ZAI_MODEL) {
console.log(`Current model: ${process.env.ZAI_MODEL} (from ZAI_MODEL env var)`);
} else {
console.log(`Current model: ${DEFAULT_ZAI_MODEL} (default)`);
}
process.exit(0);
} catch (error) {
console.error('Failed to read model configuration:', error);
process.exit(1);
}
}

// Handle "happy zai base-url set <url>" command
if (zaiSubcommand === 'base-url' && args[2] === 'set' && args[3]) {
const url = args[3];
try {
const { writeZaiConfig, readZaiConfig, DEFAULT_ZAI_BASE_URL } = await import('@/zai/runZai');

// Read existing config or create new one
const existingConfig = readZaiConfig();
const updatedConfig = {
...existingConfig,
baseUrl: url
};

writeZaiConfig(updatedConfig);
console.log(`✓ Base URL set to: ${url}`);
if (url === DEFAULT_ZAI_BASE_URL) {
console.log(' (This is the default GLM API endpoint)');
}
process.exit(0);
} catch (error) {
console.error('Failed to save base URL configuration:', error);
process.exit(1);
}
}

// Handle "happy zai model" (no subcommand) - show help
if (zaiSubcommand === 'model' && !args[2]) {
console.log('Usage: happy zai model <command>');
console.log('');
console.log('Commands:');
console.log(' set <model> Set GLM model (e.g., glm-4.7, glm-4-plus)');
console.log(' get Show current model');
console.log('');
console.log('Available models: glm-4.7, glm-4-plus, glm-4-flash, glm-4-air, glm-4-flashx');
process.exit(0);
}

// Handle zai command (main entry point)
try {
const { runZai } = await import('@/zai/runZai');

// Parse startedBy argument and collect unknown args to pass to Claude
let startedBy: 'daemon' | 'terminal' | undefined = undefined;
const unknownArgs: string[] = [];
for (let i = 1; i < args.length; i++) {
if (args[i] === '--started-by') {
startedBy = args[++i] as 'daemon' | 'terminal';
} else {
// Pass unknown arguments through to Claude
unknownArgs.push(args[i]);
// Check if this arg expects a value (simplified check for common patterns)
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
unknownArgs.push(args[++i]);
}
}
}

// Auto-start daemon for zai (same as claude/gemini)
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));
}

const { credentials } = await authAndSetupMachineIfNeeded();
await runZai({ credentials, startedBy, claudeArgs: unknownArgs });
} 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 @@ -538,6 +714,7 @@ ${chalk.bold('Usage:')}
happy auth Manage authentication
happy codex Start Codex mode
happy gemini Start Gemini mode (ACP)
happy zai Start Z.AI/GLM mode (BigModel.cn)
happy connect Connect AI vendor API keys
happy notify Send push notification
happy daemon Manage background service that allows
Expand Down
175 changes: 175 additions & 0 deletions src/zai/runZai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Z.AI/GLM CLI Entry Point
*
* This module provides the main entry point for running Claude with GLM's API
* (z.ai / BigModel.cn). It acts as a thin wrapper that sets the appropriate
* environment variables to redirect Claude's API calls to GLM's endpoint.
*
* GLM is Anthropic-compatible, so we just need to override:
* - ANTHROPIC_BASE_URL → https://open.bigmodel.cn/api/anthropic
* - ANTHROPIC_AUTH_TOKEN → GLM API key
* - ANTHROPIC_MODEL → glm-4.7 (or other GLM model)
*/

import { runClaude, StartOptions } from '@/claude/runClaude';
import { logger } from '@/ui/logger';
import { Credentials } from '@/persistence';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';

// Default GLM configuration
export const DEFAULT_ZAI_BASE_URL = 'https://open.bigmodel.cn/api/anthropic';
export const DEFAULT_ZAI_MODEL = 'glm-4.7';

// Available GLM models (for validation)
export const VALID_ZAI_MODELS = [
'glm-4.7',
'glm-4-plus',
'glm-4-flash',
'glm-4-air',
'glm-4-flashx',
];

export interface ZaiConfig {
/** GLM API key (can also use ZAI_AUTH_TOKEN env var) */
authToken?: string;
/** GLM API base URL (defaults to https://open.bigmodel.cn/api/anthropic) */
baseUrl?: string;
/** Model to use (defaults to glm-4.7) */
model?: string;
}

/**
* Read Z.AI configuration from ~/.zai/config.json
*/
export function readZaiConfig(): ZaiConfig {
const configDir = join(homedir(), '.zai');
const configPath = join(configDir, 'config.json');

if (!existsSync(configPath)) {
return {};
}

try {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
return config;
} catch (error) {
logger.warn(`[zai] Failed to parse config file: ${error}`);
return {};
}
}

/**
* Write Z.AI configuration to ~/.zai/config.json
*/
export function writeZaiConfig(config: ZaiConfig): void {
const configDir = join(homedir(), '.zai');
const configPath = join(configDir, 'config.json');

// Create directory if it doesn't exist
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}

writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
}

/**
* Get the effective Z.AI configuration by merging:
* 1. ~/.zai/config.json
* 2. Environment variables (ZAI_AUTH_TOKEN, ZAI_BASE_URL, ZAI_MODEL)
*/
export function getEffectiveZaiConfig(): {
baseUrl: string;
authToken: string;
model: string;
source: 'config' | 'env' | 'default';
} {
const fileConfig = readZaiConfig();
const envToken = process.env.ZAI_AUTH_TOKEN;
const envBaseUrl = process.env.ZAI_BASE_URL;
const envModel = process.env.ZAI_MODEL;

const authToken = envToken || fileConfig.authToken || '';
const baseUrl = envBaseUrl || fileConfig.baseUrl || DEFAULT_ZAI_BASE_URL;
const model = envModel || fileConfig.model || DEFAULT_ZAI_MODEL;

// Determine source for logging
let source: 'config' | 'env' | 'default' = 'default';
if (envToken || envBaseUrl || envModel) {
source = 'env';
} else if (fileConfig.authToken || fileConfig.baseUrl || fileConfig.model) {
source = 'config';
}

return { baseUrl, authToken, model, source };
}

/**
* Validate GLM model name
*/
export function isValidZaiModel(model: string): boolean {
return VALID_ZAI_MODELS.includes(model);
}

/**
* Main entry point for the zai command
*
* This reads the configuration from ~/.zai/config.json (or environment variables),
* builds the appropriate environment variables, and launches Claude with those
* variables set to redirect API calls to GLM's endpoint.
*/
export async function runZai(opts: {
credentials: Credentials;
startedBy?: 'daemon' | 'terminal';
claudeArgs?: string[];
}): Promise<void> {
logger.debug('[zai] ===== Z.AI MODE STARTING =====');
logger.debug('[zai] This is Claude with GLM API (z.ai / BigModel.cn)');

const config = getEffectiveZaiConfig();

// Validate that we have an auth token
if (!config.authToken) {
console.error('Error: No GLM API key found.');
console.error('');
console.error('Please set your API key using one of these methods:');
console.error(' 1. Set environment variable: export ZAI_AUTH_TOKEN="your-key"');
console.error(' 2. Save to config file: happy zai token set <your-key>');
console.error('');
console.error('Get your API key at: https://open.bigmodel.cn/');
process.exit(1);
}

// Validate model
if (!isValidZaiModel(config.model)) {
console.warn(`Warning: Unknown model "${config.model}"`);
console.warn(`Valid models: ${VALID_ZAI_MODELS.join(', ')}`);
console.warn(`Using "${config.model}" anyway...`);
}

// Build options with GLM environment variables
const claudeOptions: StartOptions = {
startedBy: opts.startedBy,
claudeEnvVars: {
ANTHROPIC_BASE_URL: config.baseUrl,
ANTHROPIC_AUTH_TOKEN: config.authToken,
ANTHROPIC_MODEL: config.model,
},
claudeArgs: opts.claudeArgs
};

logger.debug('[zai] Configuration:', {
baseUrl: config.baseUrl,
model: config.model,
source: config.source,
hasToken: !!config.authToken,
claudeArgs: opts.claudeArgs,
});

console.log(`Using GLM API (${config.model})`);

// Run Claude with custom environment variables
await runClaude(opts.credentials, claudeOptions);
}
Loading