feat(targets): providers-based Claude Code config + Claude Desktop for all platforms

claude-code.ts: switch from env.ANTHROPIC_* (session hijack) to providers.hanzo
entry — additive, non-destructive. Users switch with /model hanzo/deepseek-v4-pro
without losing the default Anthropic connection. HANZO_MODELS list reflects the
live api.hanzo.ai catalog (Zen + GLM + DeepSeek + Qwen + Kimi + Nemotron).

claude-desktop.ts: new target. Writes mcpServers.hanzo into claude_desktop_config.json.
Config path is platform-aware: ~/Library/... (macOS), %APPDATA%\Claude (Windows),
~/.config/Claude (Linux). API key passed as env var to `npx @hanzo/mcp`.

index.ts: register claudeDesktop in TARGETS.
This commit is contained in:
2026-06-29 15:08:24 -07:00
parent 8d3a4fda76
commit 8cf9e4ba70
3 changed files with 124 additions and 34 deletions
+40 -33
View File
@@ -1,13 +1,13 @@
/**
* Claude Code target.
*
* Claude Code speaks the Anthropic protocol and reads its provider from the
* `env` block of ~/.claude/settings.json:
* ANTHROPIC_AUTH_TOKEN the bearer token sent on every request
* ANTHROPIC_BASE_URL where /v1/messages lives
* Hanzo serves /v1/messages at the API root, so the base URL is just the API
* origin. We also set hasCompletedOnboarding in ~/.claude.json so a fresh
* install doesn't trap the user in the first-run wizard.
* Uses the `providers.hanzo` block in ~/.claude/settings.json — an additive,
* non-destructive entry that lets users switch to Hanzo models per-session
* (`/model hanzo/deepseek-v4-pro`) without replacing the default Anthropic
* connection. The env block is NOT touched so the current session stays intact.
*
* Config paths are identical on all platforms (home-relative). Claude Code
* also looks at CLAUDE_SETTINGS_PATH env override.
*/
import path from 'node:path';
@@ -19,20 +19,28 @@ import { type CodingTarget, type HanzoCredentials, type TargetStatus, maskKey }
const SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
const CLAUDE_JSON = path.join(os.homedir(), '.claude.json');
// Keys we own in settings.env — added on configure, removed on unconfigure.
const MANAGED_ENV = [
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_MODEL',
'API_TIMEOUT_MS',
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
] as const;
interface ClaudeSettings {
env?: Record<string, string | number>;
providers?: Record<string, {
apiKey?: string;
baseURL?: string;
name?: string;
models?: string[];
}>;
[k: string]: unknown;
}
const HANZO_MODELS = [
'zen5-ultra', 'zen5-max', 'zen5-pro', 'zen5', 'zen5-mini',
'zen4-ultra', 'zen4-max', 'zen4-pro', 'zen4', 'zen4-coder-pro', 'zen4-coder', 'zen4-thinking',
'glm-5.2', 'glm-5.1', 'glm-5',
'deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-v3.2', 'deepseek-reasoner',
'qwen3.5-397b', 'qwen3-coder',
'kimi-k2.6',
'llama-4-maverick',
'nemotron-3-ultra-550b',
'minimax-m2.5',
];
export const claudeCode: CodingTarget = {
id: 'claude-code',
displayName: 'Claude Code',
@@ -41,16 +49,14 @@ export const claudeCode: CodingTarget = {
configure(creds: HanzoCredentials): void {
const settings = readJson<ClaudeSettings>(SETTINGS);
const env = { ...settings.env };
delete env['ANTHROPIC_API_KEY']; // avoid two competing auth vars
settings.env = {
...env,
ANTHROPIC_AUTH_TOKEN: creds.apiKey,
ANTHROPIC_BASE_URL: creds.apiBase,
ANTHROPIC_MODEL: creds.model,
API_TIMEOUT_MS: '3000000',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
settings.providers = {
...settings.providers,
hanzo: {
name: 'Hanzo AI',
apiKey: creds.apiKey,
baseURL: creds.apiBase + '/v1',
models: HANZO_MODELS,
},
};
writeJson(SETTINGS, settings);
@@ -62,18 +68,19 @@ export const claudeCode: CodingTarget = {
unconfigure(): void {
const settings = readJson<ClaudeSettings>(SETTINGS);
if (!settings.env) return;
for (const key of MANAGED_ENV) delete settings.env[key];
if (Object.keys(settings.env).length === 0) delete settings.env;
if (settings.providers?.hanzo) {
delete settings.providers.hanzo;
if (Object.keys(settings.providers).length === 0) delete settings.providers;
}
writeJson(SETTINGS, settings);
},
status(): TargetStatus {
const token = readJson<ClaudeSettings>(SETTINGS).env?.['ANTHROPIC_AUTH_TOKEN'];
const key = readJson<ClaudeSettings>(SETTINGS).providers?.hanzo?.apiKey;
return {
installed: isInstalled(this.bin),
configured: typeof token === 'string' && token.length > 0,
...(typeof token === 'string' ? { apiKeyMasked: maskKey(token) } : {}),
configured: typeof key === 'string' && key.startsWith('hk-'),
...(typeof key === 'string' ? { apiKeyMasked: maskKey(key) } : {}),
};
},
};
+82
View File
@@ -0,0 +1,82 @@
/**
* Claude Desktop target.
*
* Claude Desktop is configured via claude_desktop_config.json. Location varies
* by platform:
* macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
* Windows: %APPDATA%\Claude\claude_desktop_config.json
* Linux: ~/.config/Claude/claude_desktop_config.json
*
* Hanzo MCP server is wired as a named server entry so users get Hanzo tools
* directly in Claude Desktop conversations. The API key is passed as an env var
* to the server process — it never appears in the UI.
*/
import path from 'node:path';
import os from 'node:os';
import { readJson, writeJson } from '../lib/json-config';
import { isInstalled } from '../lib/which';
import { type CodingTarget, type HanzoCredentials, type TargetStatus, maskKey } from './types';
function configPath(): string {
switch (process.platform) {
case 'darwin':
return path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
case 'win32':
return path.join(process.env['APPDATA'] ?? path.join(os.homedir(), 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
default:
return path.join(os.homedir(), '.config', 'Claude', 'claude_desktop_config.json');
}
}
interface DesktopConfig {
mcpServers?: Record<string, {
command: string;
args?: string[];
env?: Record<string, string>;
}>;
[k: string]: unknown;
}
export const claudeDesktop: CodingTarget = {
id: 'claude-desktop',
displayName: 'Claude Desktop',
bin: process.platform === 'win32' ? 'claude' : 'Claude',
installCommand: 'Download from https://claude.ai/download',
configure(creds: HanzoCredentials): void {
const config = readJson<DesktopConfig>(configPath());
config.mcpServers = {
...config.mcpServers,
hanzo: {
command: 'npx',
args: ['-y', '@hanzo/mcp'],
env: {
HANZO_API_KEY: creds.apiKey,
HANZO_API_BASE: creds.apiBase,
},
},
};
writeJson(configPath(), config);
},
unconfigure(): void {
const config = readJson<DesktopConfig>(configPath());
if (config.mcpServers?.hanzo) {
delete config.mcpServers.hanzo;
if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers;
}
writeJson(configPath(), config);
},
status(): TargetStatus {
const key = readJson<DesktopConfig>(configPath()).mcpServers?.hanzo?.env?.['HANZO_API_KEY'];
// Claude Desktop is a GUI app — check the config file exists instead of PATH.
const configured = typeof key === 'string' && key.startsWith('hk-');
return {
installed: isInstalled('claude') || isInstalled('Claude'),
configured,
...(typeof key === 'string' ? { apiKeyMasked: maskKey(key) } : {}),
};
},
};
+2 -1
View File
@@ -6,9 +6,10 @@
import type { CodingTarget } from './types';
import { claudeCode } from './claude-code';
import { claudeDesktop } from './claude-desktop';
import { codex } from './codex';
export const TARGETS: readonly CodingTarget[] = [claudeCode, codex];
export const TARGETS: readonly CodingTarget[] = [claudeCode, claudeDesktop, codex];
export function getTarget(id: string): CodingTarget | undefined {
return TARGETS.find((t) => t.id === id);