Dynamic cloud catalog + smart tiers + one-command ecosystem installer

- models: read the catalog live from api.hanzo.ai/v1/models; nothing hardcoded.
  Effort words (auto/fast/high/max/code/agent) are sugar over the cloud's own
  zen-* routing aliases — the cloud picks the concrete model. Default = zen-auto.
- claude-code: write the live model list into providers.hanzo (was a static array).
- credentials carry the live model list so configure() stays sync + orthogonal.
- config: honor HANZO_API_KEY env so the CLI works headless / in CI.
- ecosystem: one data-driven registry (dev, mcp, node, desktop+Enso, extension,
  ide, slack, github); 'hanzo install' checklist + per-component npm/guide runner.
- login wizard offers ecosystem setup as its final out-of-box step.
- add eslint flat config (deps were present, config was missing); lint clean.
- brand: add public web root (site) so fork install links white-label correctly.
This commit is contained in:
Hanzo Dev
2026-06-30 12:43:52 -07:00
parent fba6864b8e
commit 6c32048e12
13 changed files with 496 additions and 180 deletions
+39 -14
View File
@@ -42,31 +42,56 @@ codex # Codex → Hanzo Cloud
| `hanzo auth rotate` | Mint a fresh API key (old one stops working) |
| `hanzo auth revoke` | Revoke your API key |
| `hanzo auth logout` | Clear local credentials |
| `hanzo install [pkgs…]` | Install Hanzo tooling (`dev`, `mcp`, `extension`) |
| `hanzo models --tiers` | Show the smart tiers (effort words → cloud aliases) |
| `hanzo install [parts…]` | Set up the ecosystem (dev, mcp, node, apps, …) |
| `hanzo doctor` | Diagnose connectivity and tool configuration |
### Smart tiers
The catalog is read live from `api.hanzo.ai/v1/models` — nothing is hardcoded.
Beyond concrete ids (`glm-5.2`, `deepseek-v4-pro`, …) you can ask for an
**effort word** and let the cloud pick the model:
| Word | Routes to | |
| --- | --- | --- |
| `auto` | `zen-auto` | the cloud chooses (recommended default) |
| `fast` | `zen-normal` | quick, everyday |
| `high` | `zen-pro` | stronger reasoning & coding |
| `max` | `zen-max` | top tier (needs credits) |
| `code` | `zen-code` | coding-specialised |
| `agent`| `zen-agent` | agentic |
```bash
hanzo use claude-code --model high # or auto / fast / max / glm-5.2 …
hanzo models # full live catalog for your key
```
### Supported coding tools
- **Claude Code** — sets `ANTHROPIC_AUTH_TOKEN` + `ANTHROPIC_BASE_URL` in
`~/.claude/settings.json`.
- **Claude Code** — adds a `providers.hanzo` block to `~/.claude/settings.json`
(additive; switch per-session with `/model hanzo/<id>`). Choose shell-env mode
at login to make Hanzo the default for every Anthropic-compatible tool instead.
- **Codex** — adds a `[model_providers.hanzo]` provider to
`~/.codex/config.toml` (key in `~/.hanzo/env` as `HANZO_API_KEY`).
Configure one explicitly:
### The whole ecosystem
```bash
hanzo login --tool codex --model glm-5.2
hanzo use claude-code --model claude-opus-4-8
hanzo install # interactive checklist (core tools pre-checked)
hanzo install dev mcp # CLI agent + MCP server (npm)
hanzo install list # everything available
```
### Hanzo's own tools
```bash
hanzo install dev # @hanzo/dev — CLI coding agent
hanzo install mcp # @hanzo/mcp — tools, browser, cloud
hanzo install extension # @hanzo/extension — browser extension
hanzo install # dev + mcp
```
| Part | Kind | |
| --- | --- | --- |
| `dev` | npm | `@hanzo/dev` — CLI coding agent (`code`) |
| `mcp` | npm | `@hanzo/mcp` — MCP server (tools, browser, cloud) |
| `node` | guide | local inference node (bundles the engine) |
| `desktop` | guide | desktop app + the Enso browser |
| `extension` | guide | browser extension (Chrome / Firefox / Safari) |
| `ide` | guide | VS Code & JetBrains extensions |
| `slack` | guide | the Slack app |
| `github` | guide | the GitHub app |
## How it works
+17
View File
@@ -0,0 +1,17 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import globals from 'globals';
export default tseslint.config(
{ ignores: ['dist/**', 'node_modules/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
globals: { ...globals.node },
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
}
);
+32 -60
View File
@@ -1,77 +1,49 @@
/**
* `hanzo install` — install Hanzo's own tooling.
* @hanzo/dev CLI coding agent (run `hanzo dev` after)
* @hanzo/mcp Model Context Protocol server (tools, browser, cloud)
* @hanzo/extension browser extension (manual install — link printed)
* `hanzo install [components...]` — set up the Hanzo ecosystem.
* no args, interactive → pick from a checklist (core tools pre-checked)
* no args, non-interactive → install the core tools (dev, mcp)
* named → install exactly those
* `hanzo install list` → show everything available
*/
import { Command } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import { spawn } from 'node:child_process';
interface Pkg {
id: string;
pkg: string;
desc: string;
/** Browser extensions can't be `npm i -g`'d into a usable state. */
manual?: boolean;
}
const PACKAGES: Record<string, Pkg> = {
dev: { id: 'dev', pkg: '@hanzo/dev', desc: 'Hanzo Dev — CLI coding agent' },
mcp: { id: 'mcp', pkg: '@hanzo/mcp', desc: 'Hanzo MCP — tools, browser, cloud' },
extension: { id: 'extension', pkg: '@hanzo/extension', desc: 'Hanzo Browser Extension', manual: true },
};
import {
COMPONENTS,
getComponent,
installComponents,
pickComponents,
type Component,
} from '../lib/ecosystem';
export const installCmd = new Command('install')
.description('Install Hanzo tooling (dev, mcp, extension)')
.argument('[packages...]', 'Which to install; omit for dev + mcp')
.description('Install Hanzo tooling (dev, mcp, node, desktop, extension, ide, slack, github)')
.argument('[components...]', 'Which to install; omit to choose from a checklist')
.action(async (ids: string[]) => {
const chosen = ids.length > 0 ? ids : ['dev', 'mcp'];
for (const id of chosen) {
const p = PACKAGES[id];
if (!p) {
console.error(chalk.red(`Unknown package: ${id}. Try: ${Object.keys(PACKAGES).join(', ')}`));
continue;
}
if (p.manual) {
printExtensionHelp();
continue;
}
await npmInstallGlobal(p);
}
const chosen = ids.length > 0 ? resolveIds(ids) : await pickComponents();
await installComponents(chosen);
if (chosen.length > 0) console.log(chalk.dim('\n Done. `hanzo install list` shows the rest.'));
});
installCmd
.command('list')
.description('List installable Hanzo packages')
.description('List installable Hanzo ecosystem components')
.action(() => {
for (const p of Object.values(PACKAGES)) {
console.log(` ${chalk.bold(p.id.padEnd(10))} ${p.pkg.padEnd(18)} ${chalk.dim(p.desc)}`);
for (const c of COMPONENTS) {
const kind = c.kind === 'npm' ? chalk.green('npm ') : chalk.blue('guide');
console.log(` ${chalk.bold(c.id.padEnd(10))} ${kind} ${chalk.dim(c.desc)}`);
}
});
function npmInstallGlobal(p: Pkg): Promise<void> {
const spinner = ora(`Installing ${p.desc}`).start();
return new Promise((resolve) => {
const child = spawn('npm', ['install', '-g', p.pkg], { stdio: 'pipe' });
child.stderr?.on('data', (d) => (spinner.text = String(d).trim().slice(0, 60)));
child.on('close', (code) => {
if (code === 0) spinner.succeed(chalk.green(`${p.desc} installed`));
else spinner.fail(chalk.red(`Failed to install ${p.pkg} (exit ${code})`));
resolve();
});
child.on('error', (e) => {
spinner.fail(chalk.red(`Failed to install ${p.pkg}: ${e.message}`));
resolve();
});
});
}
function printExtensionHelp(): void {
console.log(chalk.bold('\n Hanzo Browser Extension'));
console.log(chalk.dim(' Chrome : chrome://extensions → Developer Mode → Load Unpacked'));
console.log(chalk.dim(' Firefox : about:debugging → This Firefox → Load Temporary Add-on'));
console.log(chalk.dim(' Download: https://hanzo.ai/extension\n'));
function resolveIds(ids: string[]): Component[] {
const out: Component[] = [];
for (const id of ids) {
const c = getComponent(id);
if (!c) {
console.error(chalk.red(`Unknown component: ${id}. Try: ${COMPONENTS.map((x) => x.id).join(', ')}`));
continue;
}
out.push(c);
}
return out;
}
+59 -15
View File
@@ -10,13 +10,20 @@ import { Command } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import inquirer from 'inquirer';
import { spawn } from 'node:child_process';
import { requestDeviceCode, pollForToken, fetchUser, DeviceAuthError } from '../lib/iam';
import { ensureApiKey } from '../lib/apikeys';
import { setConfig } from '../lib/config';
import { endpoints } from '../lib/endpoints';
import { FEATURED_MODELS, DEFAULT_MODEL, resolveModel } from '../lib/models';
import {
DEFAULT_MODEL,
resolveModel,
fetchCatalog,
aliasesFrom,
type CloudModel,
} from '../lib/models';
import { installShellEnv, detectShell } from '../lib/shell-env';
import { openUrl } from '../lib/open';
import { installComponents, pickComponents } from '../lib/ecosystem';
import { TARGETS, getTarget, codexEnvHint } from '../targets';
export interface LoginOptions {
@@ -34,7 +41,7 @@ export interface LoginOptions {
export const loginCmd = new Command('login')
.description('Sign in to Hanzo and configure your coding tools')
.option('--tool <id>', 'Configure a specific tool (claude-code, codex)')
.option('--model <id|tier>', 'Model id or tier (default, flash, pro, ultra, max)')
.option('--model <id|tier>', 'Model id or effort (auto, fast, high, max, code, agent)')
.option('--key <hk-...>', 'Use an existing Hanzo API key instead of browser login')
.option('--mode <mode>', 'Install target: shell (env vars) or tools (per-tool config)')
.option('--no-browser', 'Do not open the browser automatically')
@@ -49,8 +56,12 @@ export async function runLogin(opts: LoginOptions): Promise<void> {
try {
const apiKey = await acquireApiKey(opts);
const model = opts.model ? resolveModel(opts.model) : await pickModel();
const creds = { apiKey, apiBase: endpoints.api, model };
// One live catalog read drives both the model picker and the per-tool model
// list — the cloud is the source of truth, so nothing here is hardcoded.
const catalog = await fetchCatalog(apiKey).catch(() => [] as CloudModel[]);
const model = opts.model ? resolveModel(opts.model) : await pickModel(catalog);
const models = catalog.map((m) => m.id);
const creds = { apiKey, apiBase: endpoints.api, model, ...(models.length ? { models } : {}) };
const mode = await resolveMode(opts);
if (mode === 'shell') {
@@ -58,6 +69,7 @@ export async function runLogin(opts: LoginOptions): Promise<void> {
console.log(chalk.green(` ✓ Wrote Hanzo env vars to ${target.rcFile} (${model})`));
console.log(chalk.dim(` Open a new terminal, or: source ${target.rcFile}`));
console.log(chalk.dim(` Every Anthropic-compatible tool now uses Hanzo (${endpoints.api}).`));
await maybeOfferEcosystem(opts);
return;
}
@@ -75,6 +87,7 @@ export async function runLogin(opts: LoginOptions): Promise<void> {
}
printNextSteps(targets.map((t) => t.id), model);
await maybeOfferEcosystem(opts);
} catch (err) {
fail(err);
}
@@ -157,7 +170,7 @@ async function browserLogin(opts: LoginOptions): Promise<string> {
console.log(` Enter code ${chalk.bold.yellow(dc.userCode)}`);
console.log();
if (opts.browser) openBrowser(dc.verificationUriComplete);
if (opts.browser) openUrl(dc.verificationUriComplete);
const spinner = ora('Waiting for you to approve in the browser…').start();
const { accessToken } = await pollForToken(dc, (secs) => {
@@ -186,14 +199,34 @@ async function saveKey(key: string): Promise<string> {
return key;
}
async function pickModel(): Promise<string> {
/**
* Pick a default model from the live catalog. Smart tiers (the cloud's own
* routing aliases) lead — they keep working as the catalog changes — followed
* by the full concrete list. Premium (credit-gated) models are tagged.
*/
async function pickModel(catalog: CloudModel[]): Promise<string> {
if (catalog.length === 0) return DEFAULT_MODEL;
const aliases = aliasesFrom(catalog);
const aliasIds = new Set(aliases.map((a) => a.id));
const tag = (m: CloudModel) => (m.premium ? chalk.yellow(' (needs credits)') : '');
const choices = [
new inquirer.Separator(chalk.dim(' ── Smart tiers — the cloud picks the model ──')),
...aliases.map((m) => ({ name: `${m.id}${tag(m)}`, value: m.id })),
new inquirer.Separator(chalk.dim(' ── All models ──')),
...catalog
.filter((m) => !aliasIds.has(m.id))
.map((m) => ({ name: `${m.id}${tag(m)} ${chalk.dim(m.ownedBy)}`, value: m.id })),
];
const { model } = await inquirer.prompt<{ model: string }>([
{
type: 'list',
name: 'model',
message: 'Default model:',
choices: FEATURED_MODELS.map((m) => ({ name: m.label, value: m.id })),
choices,
default: DEFAULT_MODEL,
pageSize: 16,
},
]);
return model;
@@ -238,14 +271,25 @@ function printNextSteps(ids: string[], model: string): void {
console.log();
}
function openBrowser(url: string): void {
const cmd =
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
try {
spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
} catch {
/* user can open it manually */
/**
* Out-of-box wizard tail: offer to set up the rest of the ecosystem (dev, mcp,
* apps). Interactive only; explicit non-interactive flags skip it silently.
*/
async function maybeOfferEcosystem(opts: LoginOptions): Promise<void> {
if (!process.stdout.isTTY || opts.key || opts.tool || opts.all) return;
const { go } = await inquirer.prompt<{ go: boolean }>([
{
type: 'confirm',
name: 'go',
message: 'Set up more of the Hanzo ecosystem now (dev, mcp, apps)?',
default: false,
},
]);
if (!go) {
console.log(chalk.dim(' Later: `hanzo install`'));
return;
}
await installComponents(await pickComponents());
}
function fail(err: unknown): never {
+26 -20
View File
@@ -1,31 +1,18 @@
/**
* `hanzo models` — list models available through Hanzo Cloud for your key.
* Everything is read live from api.hanzo.ai; nothing is hardcoded.
*/
import { Command } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import { getConfig } from '../lib/config';
import { fetchModels, FEATURED_MODELS, TIERS } from '../lib/models';
import { fetchCatalog, aliasesFrom, EFFORT_ALIASES, EFFORT_ORDER } from '../lib/models';
export const modelsCmd = new Command('models')
.description('List models available through Hanzo Cloud')
.option('--featured', 'Show only the curated featured set')
.option('--tiers', 'Show the friendly capability tiers and what they map to')
.action(async (opts: { featured?: boolean; tiers?: boolean }) => {
if (opts.tiers) {
for (const [tier, id] of Object.entries(TIERS)) {
console.log(` ${chalk.bold(tier.padEnd(10))}${id}`);
}
console.log(chalk.dim('\n Use a tier anywhere a model is asked for: `hanzo use --model pro`'));
return;
}
if (opts.featured) {
for (const m of FEATURED_MODELS) console.log(` ${chalk.bold(m.id.padEnd(20))} ${chalk.dim(m.label)}`);
return;
}
.option('--tiers', 'Show the smart tiers (effort words → cloud routing aliases)')
.action(async (opts: { tiers?: boolean }) => {
const { apiKey } = await getConfig();
if (!apiKey) {
console.error(chalk.red('Not signed in. Run `hanzo login` first.'));
@@ -33,13 +20,32 @@ export const modelsCmd = new Command('models')
}
const spinner = ora('Fetching models…').start();
let catalog;
try {
const ids = await fetchModels(apiKey);
catalog = await fetchCatalog(apiKey);
spinner.stop();
for (const id of ids) console.log(` ${id}`);
console.log(chalk.dim(`\n ${ids.length} models`));
} catch (err) {
spinner.fail(chalk.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
}
if (opts.tiers) {
const live = new Set(aliasesFrom(catalog).map((a) => a.id));
console.log(chalk.bold(' Smart tiers — the cloud picks the best model:\n'));
for (const word of EFFORT_ORDER) {
const alias = EFFORT_ALIASES[word]!;
const ok = live.has(alias) ? chalk.green('●') : chalk.dim('○');
console.log(` ${ok} ${chalk.bold(word.padEnd(7))}${alias}`);
}
console.log(chalk.dim('\n Use anywhere a model is asked for: `hanzo use --model high`'));
console.log(chalk.dim(' ● = live in your catalog. The alias is a real id; the cloud routes it.'));
return;
}
const pad = Math.max(...catalog.map((m) => m.id.length));
for (const m of catalog) {
const tag = m.premium ? chalk.yellow(' needs credits') : '';
console.log(` ${m.id.padEnd(pad)} ${chalk.dim(m.ownedBy)}${tag}`);
}
console.log(chalk.dim(`\n ${catalog.length} models`));
});
+4 -3
View File
@@ -7,13 +7,13 @@ import { Command } from 'commander';
import chalk from 'chalk';
import { getConfig } from '../lib/config';
import { endpoints } from '../lib/endpoints';
import { DEFAULT_MODEL, resolveModel } from '../lib/models';
import { DEFAULT_MODEL, resolveModel, fetchModels } from '../lib/models';
import { TARGETS, getTarget, codexEnvHint } from '../targets';
export const useCmd = new Command('use')
.description('Point a coding tool at Hanzo using your saved API key')
.argument('[tool]', 'Tool id (claude-code, codex). Omit to apply to all installed tools.')
.option('--model <id|tier>', 'Model id or tier (default, flash, pro, ultra, max)', DEFAULT_MODEL)
.option('--model <id|tier>', 'Model id or effort (auto, fast, high, max, code, agent)', DEFAULT_MODEL)
.action(async (tool: string | undefined, opts: { model: string }) => {
const { apiKey } = await getConfig();
if (!apiKey) {
@@ -28,7 +28,8 @@ export const useCmd = new Command('use')
}
const model = resolveModel(opts.model);
const creds = { apiKey, apiBase: endpoints.api, model };
const models = await fetchModels(apiKey).catch(() => undefined);
const creds = { apiKey, apiBase: endpoints.api, model, ...(models ? { models } : {}) };
for (const t of targets) {
t.configure(creds);
console.log(chalk.green(`${t.displayName} → Hanzo Cloud (${model})`));
+6
View File
@@ -23,6 +23,8 @@ export interface Brand {
api: string;
/** OAuth client id seeded in IAM with the device_code grant. */
clientId: string;
/** Public web root — ecosystem install/download pages hang off it. */
site: string;
}
const BRANDS: Record<Brand['id'], Brand> = {
@@ -33,6 +35,7 @@ const BRANDS: Record<Brand['id'], Brand> = {
iam: 'https://hanzo.id/v1/iam',
api: 'https://api.hanzo.ai',
clientId: 'hanzo-app',
site: 'https://hanzo.ai',
},
lux: {
id: 'lux',
@@ -41,6 +44,7 @@ const BRANDS: Record<Brand['id'], Brand> = {
iam: 'https://lux.id/v1/iam',
api: 'https://api.lux.network',
clientId: 'lux-app',
site: 'https://lux.network',
},
zoo: {
id: 'zoo',
@@ -49,6 +53,7 @@ const BRANDS: Record<Brand['id'], Brand> = {
iam: 'https://zoolabs.id/v1/iam',
api: 'https://api.zoo.ngo',
clientId: 'zoo-app',
site: 'https://zoo.ngo',
},
zen: {
id: 'zen',
@@ -57,6 +62,7 @@ const BRANDS: Record<Brand['id'], Brand> = {
iam: 'https://id.zenlm.org/v1/iam',
api: 'https://api.zenlm.org',
clientId: 'zen-app',
site: 'https://zenlm.org',
},
};
+7 -2
View File
@@ -31,11 +31,16 @@ let cached: HanzoConfig | null = null;
export async function getConfig(): Promise<HanzoConfig> {
if (cached) return cached;
let file: HanzoConfig = {};
try {
cached = JSON.parse(await fs.readFile(CONFIG_FILE, 'utf-8')) as HanzoConfig;
file = JSON.parse(await fs.readFile(CONFIG_FILE, 'utf-8')) as HanzoConfig;
} catch {
cached = {};
/* no config file yet */
}
// HANZO_API_KEY (the same var Codex reads) overrides the stored key, so the
// helper works headless — in CI or a fresh shell — with no written config.
const envKey = process.env.HANZO_API_KEY?.trim();
cached = envKey ? { ...file, apiKey: envKey } : file;
return cached;
}
+201
View File
@@ -0,0 +1,201 @@
/**
* The Hanzo ecosystem — one registry, one installer. Every component the helper
* can set up (CLI agents, MCP, the node/engine, the desktop app + Enso browser,
* browser/IDE extensions, the Slack and GitHub apps) is one data entry here.
*
* Two install shapes, nothing more:
* • npm — a global package we can install directly (`@hanzo/dev`, …).
* • guide — an app/extension installed through a hosted flow; we open the
* canonical page and print the steps. URLs hang off the brand's web
* root, so the Lux/Zoo/Zen forks get the right links for free.
*
* Add a component by appending to COMPONENTS — the command, the wizard, and
* `install list` all pick it up with no other change.
*/
import { spawn } from 'node:child_process';
import chalk from 'chalk';
import ora from 'ora';
import inquirer from 'inquirer';
import { brand } from './brand';
import { openUrl } from './open';
export type ComponentKind = 'npm' | 'guide';
export interface Component {
/** Stable id used on the command line. */
id: string;
/** Short human label. */
label: string;
/** One-line description. */
desc: string;
kind: ComponentKind;
/** Part of the default "core tooling" set installed when none is named. */
core?: boolean;
/** npm: the global package to install. */
pkg?: string;
/** npm: what you run afterwards. */
run?: string;
/** guide: canonical page to open. */
url?: string;
/** guide: the steps to print. */
steps?: string[];
}
const site = brand.site;
export const COMPONENTS: readonly Component[] = [
{
id: 'dev',
label: `${brand.name} Dev`,
desc: 'CLI coding agent (installs the `code` command)',
kind: 'npm',
core: true,
pkg: '@hanzo/dev',
run: 'code',
},
{
id: 'mcp',
label: `${brand.name} MCP`,
desc: 'Model Context Protocol server — tools, browser, and cloud for any MCP client',
kind: 'npm',
core: true,
pkg: '@hanzo/mcp',
run: 'hanzo-mcp',
},
{
id: 'node',
label: `${brand.name} Node`,
desc: 'Local inference node (bundles the engine) for running models on your own hardware',
kind: 'guide',
url: `${site}/download`,
steps: [
'Download the node for your platform from the link above.',
'Run it once to start the local engine (serves an OpenAI-compatible API).',
'Point a tool at it with `hanzo use --model <id>` once it is running.',
],
},
{
id: 'desktop',
label: `${brand.name} Desktop & Enso`,
desc: 'Desktop app and the Enso browser — chat, agents, and the engine in one place',
kind: 'guide',
url: `${site}/download`,
steps: [
'Download the desktop app / Enso browser for your platform.',
`Sign in with the same ${brand.name} account — your models and keys carry over.`,
],
},
{
id: 'extension',
label: `${brand.name} Browser Extension`,
desc: 'Browser extension (Chrome, Firefox, Safari) — page context and actions for agents',
kind: 'guide',
url: `${site}/extension`,
steps: [
'Install from your browser store via the link above, or load it unpacked:',
` Chrome → chrome://extensions → Developer Mode → Load Unpacked`,
` Firefox → about:debugging → This Firefox → Load Temporary Add-on`,
],
},
{
id: 'ide',
label: `${brand.name} for your IDE`,
desc: 'VS Code and JetBrains extensions',
kind: 'guide',
url: `${site}/docs/ide`,
steps: [
'VS Code → search "Hanzo" in the Extensions marketplace.',
'JetBrains → search "Hanzo" in Settings → Plugins → Marketplace.',
'Sign in with your API key (saved by `hanzo login`) when prompted.',
],
},
{
id: 'slack',
label: `${brand.name} for Slack`,
desc: 'Add the Slack app to chat with agents and run tools from your workspace',
kind: 'guide',
url: `${site}/slack`,
steps: [
'Open the link and click Add to Slack; approve the workspace permissions.',
`Then type /${brand.bin} in any channel to start.`,
],
},
{
id: 'github',
label: `${brand.name} for GitHub`,
desc: 'Install the GitHub app for PR reviews, issue triage, and CI agents',
kind: 'guide',
url: `${site}/github`,
steps: [
'Open the link and install the app on your account or org.',
'Select the repositories it can access; you can change this later in GitHub settings.',
],
},
];
export function getComponent(id: string): Component | undefined {
return COMPONENTS.find((c) => c.id === id);
}
/** Install one component. npm packages are installed; guides are walked. */
export async function installComponent(c: Component): Promise<void> {
if (c.kind === 'npm') return npmInstallGlobal(c);
return walkGuide(c);
}
/** Install a set of components in order. */
export async function installComponents(cs: Component[]): Promise<void> {
for (const c of cs) await installComponent(c);
}
/**
* Let the user choose components from a checklist (core tools pre-checked).
* Non-interactive (piped/CI) returns just the core tools so it never blocks.
*/
export async function pickComponents(): Promise<Component[]> {
if (!process.stdout.isTTY) return COMPONENTS.filter((c) => c.core);
const { picked } = await inquirer.prompt<{ picked: string[] }>([
{
type: 'checkbox',
name: 'picked',
message: 'What should I set up?',
pageSize: 12,
choices: COMPONENTS.map((c) => ({
name: `${c.label} ${chalk.dim('— ' + c.desc)}`,
value: c.id,
checked: c.core ?? false,
})),
},
]);
return picked.map((id) => getComponent(id)!).filter(Boolean);
}
function walkGuide(c: Component): void {
console.log();
console.log(` ${chalk.bold(c.label)}`);
if (c.url) console.log(` ${chalk.cyan(c.url)}`);
for (const s of c.steps ?? []) console.log(chalk.dim(` ${s}`));
if (c.url) openUrl(c.url);
}
function npmInstallGlobal(c: Component): Promise<void> {
const spinner = ora(`Installing ${c.label}`).start();
return new Promise((resolve) => {
const child = spawn('npm', ['install', '-g', c.pkg!], { stdio: 'pipe' });
child.stderr?.on('data', (d) => (spinner.text = String(d).trim().slice(0, 60)));
child.on('close', (code) => {
if (code === 0) {
spinner.succeed(chalk.green(`${c.label} installed`));
if (c.run) console.log(chalk.dim(` run: ${c.run}`));
} else {
spinner.fail(chalk.red(`Failed to install ${c.pkg} (exit ${code})`));
}
resolve();
});
child.on('error', (e) => {
spinner.fail(chalk.red(`Failed to install ${c.pkg}: ${e.message}`));
resolve();
});
});
}
+78 -50
View File
@@ -1,63 +1,91 @@
/**
* Curated default models offered during setup. The live list is whatever
* api.hanzo.ai/v1/models returns; this is just the short pick-list shown when
* choosing a default for a coding tool. `fetchModels` queries the real catalog.
* Model catalog — everything here is read live from api.hanzo.ai/v1/models.
* Nothing about which models exist, what they cost, or which the cloud routes
* to is baked into this CLI: the cloud is the single source of truth, so the
* helper keeps working unchanged as the catalog evolves.
*
* The only stable thing the helper owns is a handful of friendly *effort words*
* (`fast`, `high`, `max`, …). These are pure input sugar over the cloud's own
* semantic routing aliases (`zen-auto`, `zen-pro`, …): the alias is a real model
* id, and the cloud decides server-side which concrete model it runs. So even
* the tier mapping is dynamic — we never name a concrete model here.
*/
import { endpoints } from './endpoints';
export interface ModelChoice {
export interface CloudModel {
id: string;
label: string;
/** Balance-gated: callable only with positive credits (402 otherwise). */
premium: boolean;
/** Provider/owner reported by the cloud, e.g. "hanzo", "deepseek". */
ownedBy: string;
}
/**
* Friendly capability tiers → concrete live model ids (all verified against
* api.hanzo.ai/v1/models). Pick a tier by the job, not the vendor; `hanzo use
* --model pro` resolves through `resolveModel`. The underlying id can change as
* the catalog evolves without users relearning names.
*/
export const TIERS = {
default: 'glm-5.2', // fast, strong all-rounder — the everyday default
flash: 'deepseek-v4-flash', // cheapest/fastest for high-volume, simple work
pro: 'deepseek-v4-pro', // stronger reasoning and coding
ultra: 'qwen3.5-397b', // frontier-scale dense model for the hardest tasks
max: 'zen5-max', // Hanzo's top Zen tier (premium)
} as const;
export type Tier = keyof typeof TIERS;
/**
* Resolve a user-supplied model: a tier alias (`pro`) maps to its live id, and
* any other value is treated as an explicit model id and passed through.
*/
export function resolveModel(input: string): string {
return (TIERS as Record<string, string>)[input] ?? input;
}
/**
* Curated pick-list shown during setup — tiers first (the recommended way to
* choose), then a few notable named models. GLM 5.2 (`default`) leads: fast,
* strong at coding, works through both the OpenAI and Anthropic surfaces. The
* full catalog is always available via `hanzo models`.
*/
export const FEATURED_MODELS: readonly ModelChoice[] = [
{ id: TIERS.default, label: 'default — GLM 5.2, fast all-rounder' },
{ id: TIERS.flash, label: 'flash — DeepSeek V4 Flash, cheapest & fastest' },
{ id: TIERS.pro, label: 'pro — DeepSeek V4 Pro, stronger reasoning' },
{ id: TIERS.ultra, label: 'ultra — Qwen 3.5 397B, frontier scale' },
{ id: TIERS.max, label: 'max — Zen 5 Max (Hanzo, premium)' },
{ id: 'zen4-coder-pro', label: 'Zen 4 Coder Pro (Hanzo, coding)' },
];
export const DEFAULT_MODEL = TIERS.default;
/** Fetch the live model catalog from Hanzo Cloud (OpenAI-compatible shape). */
export async function fetchModels(apiKey: string): Promise<string[]> {
/** Live catalog from Hanzo Cloud (OpenAI-compatible shape), sorted by id. */
export async function fetchCatalog(apiKey: string): Promise<CloudModel[]> {
const res = await fetch(`${endpoints.api}/v1/models`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) throw new Error(`Could not list models (${res.status} ${res.statusText})`);
const body = (await res.json()) as { data?: Array<{ id: string }> };
return (body.data ?? []).map((m) => m.id).sort();
const body = (await res.json()) as {
data?: Array<{ id: string; premium?: boolean; owned_by?: string }>;
};
return (body.data ?? [])
.map((m) => ({ id: m.id, premium: m.premium ?? false, ownedBy: m.owned_by ?? '' }))
.sort((a, b) => a.id.localeCompare(b.id));
}
/** Convenience: just the ids from the live catalog. */
export async function fetchModels(apiKey: string): Promise<string[]> {
return (await fetchCatalog(apiKey)).map((m) => m.id);
}
/**
* The cloud's semantic routing aliases — `zen-<word>` with no version digit
* (`zen-auto`, `zen-pro`, `zen-max`, `zen-code`, …). Each is a real, callable
* model id; the cloud resolves it to the best concrete model at that effort
* level. Discovered from the live catalog, never hardcoded.
*/
const ALIAS_RE = /^zen-[a-z]+$/;
export function aliasesFrom(catalog: CloudModel[]): CloudModel[] {
return catalog.filter((m) => ALIAS_RE.test(m.id));
}
/**
* Friendly effort words accepted anywhere a model is asked for. They are sugar
* over the cloud's routing aliases — the cloud still chooses the model. Listed
* cheapest→strongest; `auto` lets the cloud pick. Anything not a known word
* (e.g. a concrete id like `glm-5.2`) passes straight through.
*/
export const EFFORT_ALIASES: Record<string, string> = {
auto: 'zen-auto',
fast: 'zen-normal',
low: 'zen-normal',
normal: 'zen-normal',
high: 'zen-pro',
pro: 'zen-pro',
xhigh: 'zen-max',
max: 'zen-max',
ultra: 'zen-max',
code: 'zen-code',
coding: 'zen-code',
agent: 'zen-agent',
};
/** Effort words in display order (cheapest→strongest), for help text and prompts. */
export const EFFORT_ORDER = ['auto', 'fast', 'high', 'max', 'code', 'agent'] as const;
/** Resolve a user-supplied model: an effort word maps to its cloud alias; any
* other value is treated as an explicit model id and passed through. */
export function resolveModel(input: string): string {
return EFFORT_ALIASES[input.trim().toLowerCase()] ?? input.trim();
}
/**
* Recommended default: `zen-auto`. The cloud routes it to the best available
* model, so it keeps working no matter how the catalog changes — the most
* "dynamic, works no matter what" choice for a fresh setup. Users can pick any
* concrete id in the wizard instead.
*/
export const DEFAULT_MODEL = 'zen-auto';
+20
View File
@@ -0,0 +1,20 @@
/**
* Open a URL in the user's default browser. Best-effort and non-fatal: if it
* can't launch (headless, no DE), the caller has already printed the link.
*/
import { spawn } from 'node:child_process';
export function openUrl(url: string): void {
const cmd =
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
try {
spawn(cmd, [url], {
stdio: 'ignore',
detached: true,
shell: process.platform === 'win32',
}).unref();
} catch {
/* the link was printed; the user can open it manually */
}
}
+2 -15
View File
@@ -29,20 +29,6 @@ interface ClaudeSettings {
[k: string]: unknown;
}
const HANZO_MODELS = [
'zen5-ultra', 'zen5-max', 'zen5-pro', 'zen5', 'zen5-coder', 'zen5-flash', 'zen5-mini',
'zen4-ultra', 'zen4-max', 'zen4-pro', 'zen4', 'zen4-coder-pro', 'zen4-coder', 'zen4-thinking', 'zen4-mini',
'glm-5.2', 'glm-5.1', 'glm-5',
'deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-v3.2', 'deepseek-reasoner',
'kimi-k2.6', 'kimi-k2.5', 'kimi-k2',
'qwen3.5-397b', 'qwen3-coder', 'qwen3-coder-flash',
'llama-4-maverick',
'minimax-m2.5',
'nemotron-3-ultra-550b', 'nemotron-3-super-120b', 'nemotron-3-nano',
'gemma-4-31b', 'gemma-3-31b',
'mimo-v2.5-pro', 'mimo-v2.5',
];
export const claudeCode: CodingTarget = {
id: 'claude-code',
displayName: 'Claude Code',
@@ -57,7 +43,8 @@ export const claudeCode: CodingTarget = {
name: 'Hanzo AI',
apiKey: creds.apiKey,
baseURL: creds.apiBase + '/v1',
models: HANZO_MODELS,
// Live catalog from the cloud; fall back to just the default if offline.
models: creds.models && creds.models.length > 0 ? creds.models : [creds.model],
},
};
writeJson(SETTINGS, settings);
+5 -1
View File
@@ -8,8 +8,12 @@ export interface HanzoCredentials {
apiKey: string;
/** Cloud API base, e.g. https://api.hanzo.ai (no trailing slash). */
apiBase: string;
/** Default model id to select, e.g. claude-opus-4-8 or glm-5.2. */
/** Default model id to select, e.g. zen-auto or glm-5.2. */
model: string;
/** Live model ids from the cloud catalog. Targets that list selectable models
* (e.g. Claude Code) write these; the command layer fetches them once so
* configuration stays in step with the catalog. Omitted ⇒ just the default. */
models?: string[];
}
export interface TargetStatus {