Rebuild @hanzo/helper around real hanzo.id device login + coding-tool config
Verified against the actual Hanzo sources (iam, ai, console2, login): - Device login (RFC 8628) against hanzo.id /oauth/device + /oauth/token, form-encoded, client_id hanzo-app, body-keyed pending/slow_down handling. - API key = the per-user IAM accessKey (hk-): read via /v1/get-account, self-minted via /v1/iam/mint-user-keys (authorized as self by the login JWT). - CodingTarget abstraction: one interface, Claude Code (settings.json env) and Codex (config.toml provider) implementations. Verified round-trip preserves all unrelated user config. - Commands: login, use/unuse, auth (status/key/rotate/revoke/logout), status, models, install (dev/mcp/extension), doctor. - Endpoints overridable for white-label (HANZO_IAM_URL/API_URL/CLIENT_ID). - TypeScript 6, tsup bundle, clean typecheck.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
dist
|
||||
node_modules
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
coverage
|
||||
.npm
|
||||
@@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 Hanzo AI
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,117 @@
|
||||
# @hanzo/helper
|
||||
|
||||
> Sign in to Hanzo and use its AI models in Claude Code, Codex, and other coding tools.
|
||||
|
||||
One command points your coding tool at [Hanzo Cloud](https://hanzo.ai): browser
|
||||
device login, an API key minted for you, and the tool configured to talk to
|
||||
`api.hanzo.ai` — which speaks both the OpenAI (`/v1/chat/completions`) and
|
||||
Anthropic (`/v1/messages`) protocols, so every model (Claude, GLM, GPT,
|
||||
DeepSeek, Kimi, …) works in tools built for either.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npx @hanzo/helper login
|
||||
```
|
||||
|
||||
That will:
|
||||
|
||||
1. Open `hanzo.id` and show you a device code to approve.
|
||||
2. Mint (or reuse) your `hk-` Cloud API key.
|
||||
3. Let you pick a default model.
|
||||
4. Configure the coding tools you choose.
|
||||
|
||||
Then just run your tool — it's already talking to Hanzo:
|
||||
|
||||
```bash
|
||||
claude # Claude Code → Hanzo Cloud
|
||||
codex # Codex → Hanzo Cloud
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `hanzo login` | Device login, mint API key, configure tools |
|
||||
| `hanzo use [tool]` | Point a tool at Hanzo using your saved key |
|
||||
| `hanzo unuse [tool]` | Detach a tool from Hanzo (keep its other settings) |
|
||||
| `hanzo status` | Session + every tool's configuration state |
|
||||
| `hanzo models` | List models available to your key |
|
||||
| `hanzo auth status` | Who you're signed in as |
|
||||
| `hanzo auth key [--reveal]` | Show your current API key |
|
||||
| `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 doctor` | Diagnose connectivity and tool configuration |
|
||||
|
||||
### Supported coding tools
|
||||
|
||||
- **Claude Code** — sets `ANTHROPIC_AUTH_TOKEN` + `ANTHROPIC_BASE_URL` in
|
||||
`~/.claude/settings.json`.
|
||||
- **Codex** — adds a `[model_providers.hanzo]` provider to
|
||||
`~/.codex/config.toml` (key in `~/.hanzo/env` as `HANZO_API_KEY`).
|
||||
|
||||
Configure one explicitly:
|
||||
|
||||
```bash
|
||||
hanzo login --tool codex --model glm-5.2
|
||||
hanzo use claude-code --model claude-opus-4-8
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
hanzo login
|
||||
│
|
||||
├── device login ─────────────▶ hanzo.id (RFC 8628; proxies to iam.hanzo.ai)
|
||||
│ POST /oauth/device → user_code + verification_uri
|
||||
│ POST /oauth/token (poll) → access_token
|
||||
│
|
||||
├── mint API key ─────────────▶ iam POST /v1/iam/mint-user-keys → hk-…
|
||||
│ (self-authorized by your login token)
|
||||
│
|
||||
└── configure tools ──────────▶ ~/.claude/settings.json
|
||||
~/.codex/config.toml
|
||||
│
|
||||
your coding tool ──────────────────▶ api.hanzo.ai
|
||||
OpenAI /v1/chat/completions (one key, every model)
|
||||
Anthropic /v1/messages
|
||||
```
|
||||
|
||||
### Self-hosted / white-label
|
||||
|
||||
Point the CLI at a different deployment with environment variables:
|
||||
|
||||
| Var | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `HANZO_IAM_URL` | `https://hanzo.id` | Identity / device login |
|
||||
| `HANZO_API_URL` | `https://api.hanzo.ai` | Cloud API (keys, inference) |
|
||||
| `HANZO_CLIENT_ID` | `hanzo-app` | OAuth client id (e.g. `lux-app`) |
|
||||
|
||||
## Configuration
|
||||
|
||||
`~/.hanzo/config.json` (mode `0600`) holds your login token, API key, and
|
||||
identity. `~/.hanzo/env` holds the raw key for tools that read it from the
|
||||
environment.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build # tsup → dist/
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "@hanzo/helper",
|
||||
"version": "1.0.0",
|
||||
"description": "Hanzo CLI helper for API access, cloud services, MCP, and plugins",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"bin": {
|
||||
"hanzo": "./dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsx src/cli.ts",
|
||||
"start": "node dist/cli.js",
|
||||
"clean": "rm -rf dist",
|
||||
"prepublishOnly": "pnpm run clean && pnpm run build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"keywords": [
|
||||
"hanzo",
|
||||
"cli",
|
||||
"mcp",
|
||||
"ai",
|
||||
"cloud",
|
||||
"platform"
|
||||
],
|
||||
"author": "Hanzo AI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/helper"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/hanzoai/helper/issues"
|
||||
},
|
||||
"homepage": "https://hanzo.ai",
|
||||
"packageManager": "pnpm@10.15.0",
|
||||
"dependencies": {
|
||||
"chalk": "^5.4.1",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^13.1.0",
|
||||
"inquirer": "^12.0.0",
|
||||
"ora": "^8.2.0",
|
||||
"smol-toml": "^1.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@types/inquirer": "^9.0.8",
|
||||
"@types/node": "^22.10.5",
|
||||
"eslint": "^9.18.0",
|
||||
"globals": "^16.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
}
|
||||
}
|
||||
Generated
+2644
File diff suppressed because it is too large
Load Diff
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @hanzo/helper — sign in to Hanzo and point your coding tools at Hanzo Cloud.
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { loginCmd } from './commands/login';
|
||||
import { useCmd, unuseCmd } from './commands/use';
|
||||
import { authCmd } from './commands/auth';
|
||||
import { installCmd } from './commands/install';
|
||||
import { statusCmd } from './commands/status';
|
||||
import { modelsCmd } from './commands/models';
|
||||
import { doctorCmd } from './commands/doctor';
|
||||
import { version } from './lib/version';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('hanzo')
|
||||
.description('Sign in to Hanzo and use its AI models in Claude Code, Codex, and more')
|
||||
.version(version);
|
||||
|
||||
program.addCommand(loginCmd);
|
||||
program.addCommand(useCmd);
|
||||
program.addCommand(unuseCmd);
|
||||
program.addCommand(authCmd);
|
||||
program.addCommand(installCmd);
|
||||
program.addCommand(statusCmd);
|
||||
program.addCommand(modelsCmd);
|
||||
program.addCommand(doctorCmd);
|
||||
|
||||
program.action(() => program.outputHelp());
|
||||
|
||||
await program.parseAsync(process.argv);
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* `hanzo auth` — inspect or clear the saved session, and manage the `hk-` key.
|
||||
*
|
||||
* Hanzo issues ONE per-user Cloud API key (the IAM accessKey). There is no list
|
||||
* of named keys; you read it, rotate it, or revoke it.
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { getConfig, setConfig, clearConfig } from '../lib/config';
|
||||
import { readApiKey, mintApiKey, revokeApiKey } from '../lib/apikeys';
|
||||
import { maskKey } from '../targets';
|
||||
|
||||
export const authCmd = new Command('auth').description('Manage your Hanzo session and API key');
|
||||
|
||||
authCmd
|
||||
.command('status')
|
||||
.description('Show who you are signed in as')
|
||||
.action(async () => {
|
||||
const { user, apiKey, accessToken } = await getConfig();
|
||||
if (!apiKey && !accessToken) {
|
||||
console.log(chalk.yellow('Not signed in. Run `hanzo login`.'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(chalk.green('✓ Signed in'));
|
||||
if (user) {
|
||||
console.log(chalk.dim(` ${user.name}${user.email ? ` <${user.email}>` : ''}`));
|
||||
if (user.org) console.log(chalk.dim(` org: ${user.org}`));
|
||||
}
|
||||
if (apiKey) console.log(chalk.dim(` key: ${maskKey(apiKey)}`));
|
||||
});
|
||||
|
||||
authCmd
|
||||
.command('logout')
|
||||
.description('Forget the saved session and API key (local only)')
|
||||
.action(async () => {
|
||||
await clearConfig();
|
||||
console.log(chalk.green('✓ Signed out (local credentials cleared)'));
|
||||
});
|
||||
|
||||
authCmd
|
||||
.command('key')
|
||||
.description('Show your current Cloud API key')
|
||||
.option('--reveal', 'Print the full key instead of a masked form')
|
||||
.action(async (opts: { reveal?: boolean }) => {
|
||||
const token = await requireToken();
|
||||
const spinner = ora('Reading key…').start();
|
||||
try {
|
||||
const key = await readApiKey(token);
|
||||
spinner.stop();
|
||||
if (!key) {
|
||||
console.log(chalk.yellow('No API key yet. Create one with `hanzo auth rotate`.'));
|
||||
return;
|
||||
}
|
||||
console.log(opts.reveal ? key : maskKey(key));
|
||||
} catch (err) {
|
||||
spinner.fail(chalk.red(err instanceof Error ? err.message : String(err)));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
authCmd
|
||||
.command('rotate')
|
||||
.description('Mint a fresh Cloud API key (the old one stops working)')
|
||||
.action(async () => {
|
||||
const token = await requireToken();
|
||||
const spinner = ora('Minting key…').start();
|
||||
try {
|
||||
const key = await mintApiKey(token);
|
||||
await setConfig((c) => ({ ...c, apiKey: key }));
|
||||
spinner.succeed(chalk.green('New API key minted'));
|
||||
console.log(chalk.bold(key));
|
||||
console.log(chalk.dim('Saved locally and shown once. Re-run `hanzo use` to update your tools.'));
|
||||
} catch (err) {
|
||||
spinner.fail(chalk.red(err instanceof Error ? err.message : String(err)));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
authCmd
|
||||
.command('revoke')
|
||||
.description('Revoke your Cloud API key')
|
||||
.action(async () => {
|
||||
const token = await requireToken();
|
||||
try {
|
||||
await revokeApiKey(token);
|
||||
await setConfig((c) => ({ ...c, apiKey: undefined }));
|
||||
console.log(chalk.green('✓ API key revoked'));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
async function requireToken(): Promise<string> {
|
||||
const { accessToken } = await getConfig();
|
||||
if (!accessToken) {
|
||||
console.error(chalk.red('Not signed in. Run `hanzo login` first.'));
|
||||
process.exit(1);
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* `hanzo doctor` — quick health check: reachability, session, tool config.
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { getConfig } from '../lib/config';
|
||||
import { endpoints } from '../lib/endpoints';
|
||||
import { TARGETS } from '../targets';
|
||||
|
||||
interface Check {
|
||||
name: string;
|
||||
pass: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const doctorCmd = new Command('doctor')
|
||||
.description('Diagnose Hanzo connectivity and tool configuration')
|
||||
.action(async () => {
|
||||
const checks: Check[] = [];
|
||||
|
||||
checks.push(await reachable('IAM', `${endpoints.iam}/api/health`));
|
||||
checks.push(await reachable('Cloud API', `${endpoints.api}/v1/health`));
|
||||
|
||||
const { apiKey, user } = await getConfig();
|
||||
checks.push({
|
||||
name: 'Session',
|
||||
pass: Boolean(apiKey),
|
||||
detail: apiKey ? `signed in${user ? ` as ${user.name}` : ''}` : 'run `hanzo login`',
|
||||
});
|
||||
|
||||
const configured = TARGETS.filter((t) => t.status().configured).map((t) => t.displayName);
|
||||
checks.push({
|
||||
name: 'Coding tools',
|
||||
pass: configured.length > 0,
|
||||
detail: configured.length > 0 ? `using Hanzo: ${configured.join(', ')}` : 'none configured',
|
||||
});
|
||||
|
||||
console.log();
|
||||
for (const c of checks) {
|
||||
const icon = c.pass ? chalk.green('✓') : chalk.red('✗');
|
||||
console.log(` ${icon} ${c.name.padEnd(14)} ${chalk.dim(c.detail)}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
if (!checks.every((c) => c.pass)) process.exit(1);
|
||||
});
|
||||
|
||||
async function reachable(name: string, url: string): Promise<Check> {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(8000) });
|
||||
// Any HTTP response means the host is up; we don't require 200.
|
||||
return { name, pass: res.status < 500, detail: `${new URL(url).host} (${res.status})` };
|
||||
} catch (err) {
|
||||
return { name, pass: false, detail: `unreachable: ${err instanceof Error ? err.message : err}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* `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)
|
||||
*/
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
export const installCmd = new Command('install')
|
||||
.description('Install Hanzo tooling (dev, mcp, extension)')
|
||||
.argument('[packages...]', 'Which to install; omit for dev + mcp')
|
||||
.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);
|
||||
}
|
||||
});
|
||||
|
||||
installCmd
|
||||
.command('list')
|
||||
.description('List installable Hanzo packages')
|
||||
.action(() => {
|
||||
for (const p of Object.values(PACKAGES)) {
|
||||
console.log(` ${chalk.bold(p.id.padEnd(10))} ${p.pkg.padEnd(18)} ${chalk.dim(p.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'));
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* `hanzo login` — the one command that does everything:
|
||||
* 1. Device login against hanzo.id (no password typed into the terminal).
|
||||
* 2. Mint (or reuse) a Hanzo API key.
|
||||
* 3. Pick a default model.
|
||||
* 4. Point the chosen coding tools (Claude Code, Codex) at Hanzo Cloud.
|
||||
*/
|
||||
|
||||
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 } from '../lib/models';
|
||||
import { TARGETS, getTarget, codexEnvHint } from '../targets';
|
||||
|
||||
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>', 'Default model to use', DEFAULT_MODEL)
|
||||
.option('--no-browser', 'Do not open the browser automatically')
|
||||
.option('--all', 'Configure every installed tool without prompting')
|
||||
.action(async (opts: { tool?: string; model: string; browser: boolean; all?: boolean }) => {
|
||||
try {
|
||||
// 1. Device login ----------------------------------------------------
|
||||
const dc = await requestDeviceCode();
|
||||
|
||||
console.log();
|
||||
console.log(chalk.bold(' Sign in to Hanzo'));
|
||||
console.log(` Visit ${chalk.cyan(dc.verificationUri)}`);
|
||||
console.log(` Enter code ${chalk.bold.yellow(dc.userCode)}`);
|
||||
console.log();
|
||||
|
||||
if (opts.browser) openBrowser(dc.verificationUriComplete);
|
||||
|
||||
const spinner = ora('Waiting for you to approve in the browser…').start();
|
||||
const { accessToken } = await pollForToken(dc, (secs) => {
|
||||
spinner.text = `Waiting for approval… (${secs}s left)`;
|
||||
});
|
||||
spinner.succeed('Signed in');
|
||||
|
||||
const user = await fetchUser(accessToken).catch(() => undefined);
|
||||
if (user) console.log(chalk.dim(` ${user.name}${user.email ? ` <${user.email}>` : ''}`));
|
||||
|
||||
// 2. API key ---------------------------------------------------------
|
||||
const keySpinner = ora('Provisioning API key…').start();
|
||||
const apiKey = await ensureApiKey(accessToken);
|
||||
keySpinner.succeed('API key ready');
|
||||
|
||||
await setConfig((c) => ({
|
||||
...c,
|
||||
accessToken,
|
||||
apiKey,
|
||||
...(user ? { user } : {}),
|
||||
}));
|
||||
|
||||
// 3. Model -----------------------------------------------------------
|
||||
const model = opts.model ?? (await pickModel());
|
||||
|
||||
// 4. Configure tools -------------------------------------------------
|
||||
const targets = await resolveTargets(opts);
|
||||
if (targets.length === 0) {
|
||||
console.log(chalk.yellow('\nNo coding tools selected. Nothing configured.'));
|
||||
console.log(chalk.dim('Your API key is saved; run `hanzo use <tool>` later.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const creds = { apiKey, apiBase: endpoints.api, model };
|
||||
for (const t of targets) {
|
||||
t.configure(creds);
|
||||
console.log(chalk.green(` ✓ ${t.displayName} → Hanzo Cloud (${model})`));
|
||||
}
|
||||
|
||||
printNextSteps(targets.map((t) => t.id), model);
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
|
||||
async function pickModel(): Promise<string> {
|
||||
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 })),
|
||||
default: DEFAULT_MODEL,
|
||||
},
|
||||
]);
|
||||
return model;
|
||||
}
|
||||
|
||||
async function resolveTargets(opts: { tool?: string; all?: boolean }) {
|
||||
if (opts.tool) {
|
||||
const t = getTarget(opts.tool);
|
||||
if (!t) throw new Error(`Unknown tool: ${opts.tool}. Try one of: ${TARGETS.map((x) => x.id).join(', ')}`);
|
||||
return [t];
|
||||
}
|
||||
|
||||
const installed = TARGETS.filter((t) => t.status().installed);
|
||||
if (opts.all) return installed.length > 0 ? installed : [...TARGETS];
|
||||
|
||||
const { picked } = await inquirer.prompt<{ picked: string[] }>([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'picked',
|
||||
message: 'Configure which coding tools?',
|
||||
choices: TARGETS.map((t) => ({
|
||||
name: t.status().installed ? `${t.displayName} ${chalk.dim('(installed)')}` : t.displayName,
|
||||
value: t.id,
|
||||
checked: t.status().installed,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
return picked.map((id) => getTarget(id)!).filter(Boolean);
|
||||
}
|
||||
|
||||
function printNextSteps(ids: string[], model: string): void {
|
||||
console.log();
|
||||
console.log(chalk.bold(' You are all set.'));
|
||||
if (ids.includes('claude-code')) {
|
||||
console.log(` ${chalk.cyan('claude')} then ask anything — it now talks to Hanzo (${model}).`);
|
||||
}
|
||||
if (ids.includes('codex')) {
|
||||
console.log(` ${chalk.cyan('codex')} Hanzo provider configured.`);
|
||||
console.log(chalk.dim(` Add to your shell so Codex sees the key:`));
|
||||
console.log(chalk.dim(` ${codexEnvHint()}`));
|
||||
}
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
||||
function fail(err: unknown): never {
|
||||
const msg = err instanceof DeviceAuthError || err instanceof Error ? err.message : String(err);
|
||||
console.error(chalk.red(`\n✗ ${msg}`));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* `hanzo models` — list models available through Hanzo Cloud for your key.
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { getConfig } from '../lib/config';
|
||||
import { fetchModels, FEATURED_MODELS } from '../lib/models';
|
||||
|
||||
export const modelsCmd = new Command('models')
|
||||
.description('List models available through Hanzo Cloud')
|
||||
.option('--featured', 'Show only the curated featured set')
|
||||
.action(async (opts: { featured?: boolean }) => {
|
||||
if (opts.featured) {
|
||||
for (const m of FEATURED_MODELS) console.log(` ${chalk.bold(m.id.padEnd(20))} ${chalk.dim(m.label)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { apiKey } = await getConfig();
|
||||
if (!apiKey) {
|
||||
console.error(chalk.red('Not signed in. Run `hanzo login` first.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const spinner = ora('Fetching models…').start();
|
||||
try {
|
||||
const ids = await fetchModels(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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* `hanzo status` — one screen showing the session and every coding tool's state.
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { getConfig } from '../lib/config';
|
||||
import { endpoints } from '../lib/endpoints';
|
||||
import { TARGETS, maskKey } from '../targets';
|
||||
|
||||
export const statusCmd = new Command('status')
|
||||
.description('Show session and coding-tool configuration')
|
||||
.action(async () => {
|
||||
const { user, apiKey } = await getConfig();
|
||||
|
||||
console.log(chalk.bold('\n Session'));
|
||||
if (apiKey) {
|
||||
console.log(` ${chalk.green('●')} signed in${user ? ` as ${user.name}` : ''}`);
|
||||
console.log(chalk.dim(` key ${maskKey(apiKey)}`));
|
||||
console.log(chalk.dim(` api ${endpoints.api}`));
|
||||
} else {
|
||||
console.log(` ${chalk.yellow('○')} not signed in — run ${chalk.cyan('hanzo login')}`);
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\n Coding tools'));
|
||||
for (const t of TARGETS) {
|
||||
const s = t.status();
|
||||
const dot = s.configured ? chalk.green('●') : s.installed ? chalk.yellow('○') : chalk.dim('○');
|
||||
const state = s.configured
|
||||
? chalk.green('using Hanzo')
|
||||
: s.installed
|
||||
? chalk.yellow('installed, not configured')
|
||||
: chalk.dim('not installed');
|
||||
console.log(` ${dot} ${t.displayName.padEnd(14)} ${state}`);
|
||||
}
|
||||
console.log();
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* `hanzo use <tool>` / `hanzo unuse <tool>` — point an already-installed coding
|
||||
* tool at Hanzo (or detach it) using the API key saved by `hanzo login`.
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { getConfig } from '../lib/config';
|
||||
import { endpoints } from '../lib/endpoints';
|
||||
import { DEFAULT_MODEL } 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>', 'Default model to use', DEFAULT_MODEL)
|
||||
.action(async (tool: string | undefined, opts: { model: string }) => {
|
||||
const { apiKey } = await getConfig();
|
||||
if (!apiKey) {
|
||||
console.error(chalk.red('Not signed in. Run `hanzo login` first.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targets = tool ? [requireTarget(tool)] : TARGETS.filter((t) => t.status().installed);
|
||||
if (targets.length === 0) {
|
||||
console.log(chalk.yellow('No installed coding tools found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const creds = { apiKey, apiBase: endpoints.api, model: opts.model };
|
||||
for (const t of targets) {
|
||||
t.configure(creds);
|
||||
console.log(chalk.green(`✓ ${t.displayName} → Hanzo Cloud (${opts.model})`));
|
||||
if (t.id === 'codex') console.log(chalk.dim(` ${codexEnvHint()}`));
|
||||
}
|
||||
});
|
||||
|
||||
export const unuseCmd = new Command('unuse')
|
||||
.description('Detach a coding tool from Hanzo (restore its other settings)')
|
||||
.argument('[tool]', 'Tool id (claude-code, codex). Omit to detach all.')
|
||||
.action(async (tool: string | undefined) => {
|
||||
const targets = tool ? [requireTarget(tool)] : [...TARGETS];
|
||||
for (const t of targets) {
|
||||
if (!t.status().configured) continue;
|
||||
t.unconfigure();
|
||||
console.log(chalk.green(`✓ ${t.displayName} detached from Hanzo`));
|
||||
}
|
||||
});
|
||||
|
||||
function requireTarget(id: string) {
|
||||
const t = getTarget(id);
|
||||
if (!t) {
|
||||
console.error(
|
||||
chalk.red(`Unknown tool: ${id}. Try one of: ${TARGETS.map((x) => x.id).join(', ')}`)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @hanzo/helper — programmatic surface.
|
||||
*
|
||||
* The CLI is the primary interface; these exports let other Hanzo tooling reuse
|
||||
* the login flow, key management, and coding-tool configuration.
|
||||
*/
|
||||
|
||||
export * from './lib/endpoints';
|
||||
export * from './lib/config';
|
||||
export * from './lib/iam';
|
||||
export * from './lib/apikeys';
|
||||
export * from './lib/models';
|
||||
export * from './targets';
|
||||
export { version } from './lib/version';
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Hanzo Cloud API key — the per-user `hk-` credential presented as
|
||||
* `Authorization: Bearer hk-…` to every OpenAI/Anthropic-compatible endpoint.
|
||||
*
|
||||
* The key is the IAM user's `accessKey` (object/user.go). Two authoritative ops,
|
||||
* both authorized by the device-login JWT as the *self* caller
|
||||
* (iam resolveTargetUserForKeys: admin OR self OR allowlisted app):
|
||||
*
|
||||
* read GET api.hanzo.ai/v1/get-account → claims.accessKey
|
||||
* mint POST hanzo.id/v1/iam/mint-user-keys → { accessKey } (self)
|
||||
*
|
||||
* `ensureApiKey` reads the existing key and only mints when the account has none
|
||||
* — minting *rotates*, so we never clobber a key the user is already using.
|
||||
*/
|
||||
|
||||
import { endpoints } from './endpoints';
|
||||
|
||||
export class ApiKeyError extends Error {}
|
||||
|
||||
/** Read the signed-in user's current `hk-` key, or undefined if none exists. */
|
||||
export async function readApiKey(accessToken: string): Promise<string | undefined> {
|
||||
const res = await fetch(`${endpoints.api}/v1/get-account`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new ApiKeyError(`Could not read account (${res.status} ${res.statusText})`);
|
||||
}
|
||||
// get-account returns IAM Claims; the user fields are inlined on the claims.
|
||||
const claims = (await res.json()) as { accessKey?: string; data?: { accessKey?: string } };
|
||||
const key = claims.accessKey ?? claims.data?.accessKey;
|
||||
return key && key.startsWith('hk-') ? key : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint (rotate) the signed-in user's `hk-` key. IAM authorizes the caller as
|
||||
* *self* from the bearer JWT — no `id` param means "operate on me". Returns the
|
||||
* full key, shown once.
|
||||
*/
|
||||
export async function mintApiKey(accessToken: string): Promise<string> {
|
||||
const res = await fetch(`${endpoints.iam}/v1/iam/mint-user-keys`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
|
||||
});
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { status?: string; msg?: string; data?: { accessKey?: string } }
|
||||
| null;
|
||||
if (!res.ok || !body || body.status !== 'ok') {
|
||||
throw new ApiKeyError(body?.msg || `Could not mint API key (HTTP ${res.status})`);
|
||||
}
|
||||
const key = body.data?.accessKey;
|
||||
if (!key) throw new ApiKeyError('IAM did not return an access key');
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Revoke the signed-in user's `hk-` key (rotates to empty; takes effect ~5m). */
|
||||
export async function revokeApiKey(accessToken: string): Promise<void> {
|
||||
const res = await fetch(`${endpoints.iam}/v1/iam/revoke-user-keys`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) throw new ApiKeyError(`Could not revoke API key (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a usable `hk-` key: reuse the account's existing one, else mint a new
|
||||
* one. Reading first means re-running `hanzo login` does not rotate (and break)
|
||||
* a key already wired into the user's coding tools.
|
||||
*/
|
||||
export async function ensureApiKey(accessToken: string): Promise<string> {
|
||||
const existing = await readApiKey(accessToken).catch(() => undefined);
|
||||
return existing ?? mintApiKey(accessToken);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Persistent credential + preference store for @hanzo/helper.
|
||||
*
|
||||
* Lives at ~/.hanzo/config.json with 0600 permissions (it holds a token).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.hanzo');
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
||||
|
||||
export interface HanzoConfig {
|
||||
/** OAuth access token from device login (used to mint API keys). */
|
||||
accessToken?: string;
|
||||
/** Long-lived Hanzo API key (hk-...) used for inference. */
|
||||
apiKey?: string;
|
||||
/** Signed-in user identity, cached for display. */
|
||||
user?: UserInfo;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
org?: string;
|
||||
}
|
||||
|
||||
let cached: HanzoConfig | null = null;
|
||||
|
||||
export async function getConfig(): Promise<HanzoConfig> {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
cached = JSON.parse(await fs.readFile(CONFIG_FILE, 'utf-8')) as HanzoConfig;
|
||||
} catch {
|
||||
cached = {};
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export async function setConfig(
|
||||
updater: (config: HanzoConfig) => HanzoConfig
|
||||
): Promise<HanzoConfig> {
|
||||
const updated = updater(await getConfig());
|
||||
cached = updated;
|
||||
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(updated, null, 2), { mode: 0o600 });
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function clearConfig(): Promise<void> {
|
||||
cached = {};
|
||||
await fs.rm(CONFIG_FILE, { force: true });
|
||||
}
|
||||
|
||||
export function getConfigDir(): string {
|
||||
return CONFIG_DIR;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Single source of truth for Hanzo service endpoints.
|
||||
*
|
||||
* Overridable via environment for self-hosted / white-label deployments:
|
||||
* HANZO_IAM_URL identity SPA (device login) default https://hanzo.id
|
||||
* HANZO_API_URL cloud API (keys, inference) default https://api.hanzo.ai
|
||||
*
|
||||
* hanzo.id is the Hanzo ID SPA/proxy: it normalizes RFC OAuth paths
|
||||
* (/oauth/device, /oauth/token) onto IAM's backend (iam.hanzo.ai /v1/iam/*) and
|
||||
* hosts the human-facing approval page (/login/oauth/device). The CLI only ever
|
||||
* talks to the public hanzo.id surface.
|
||||
*/
|
||||
|
||||
const env = (key: string, fallback: string): string =>
|
||||
process.env[key]?.replace(/\/+$/, '') ?? fallback;
|
||||
|
||||
export const endpoints = {
|
||||
/** Hanzo ID — device login + approval page live here. */
|
||||
get iam(): string {
|
||||
return env('HANZO_IAM_URL', 'https://hanzo.id');
|
||||
},
|
||||
/** Cloud API — API keys, user info, and OpenAI/Anthropic-compatible inference. */
|
||||
get api(): string {
|
||||
return env('HANZO_API_URL', 'https://api.hanzo.ai');
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** RFC 8628 OAuth paths on the Hanzo ID surface. */
|
||||
export const OAUTH_PATHS = {
|
||||
deviceAuthorization: '/oauth/device',
|
||||
token: '/oauth/token',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* OAuth client id the CLI identifies as. `hanzo-app` is the canonical brand
|
||||
* CLI/desktop application seeded in IAM with the device_code grant (per
|
||||
* iam/cmd/iam/cli/init_apps.go). Override for white-label brands (lux-app, …).
|
||||
*/
|
||||
export const CLIENT_ID = env('HANZO_CLIENT_ID', 'hanzo-app');
|
||||
|
||||
/** Device-code grant type per RFC 8628. */
|
||||
export const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Hanzo IAM device-authorization flow (RFC 8628).
|
||||
*
|
||||
* The CLI cannot show a browser, so it asks IAM for a short user code, tells
|
||||
* the user where to enter it, and polls until they approve. No local callback
|
||||
* server, no client secret — exactly what a coding-tool installer needs.
|
||||
*/
|
||||
|
||||
import { endpoints, OAUTH_PATHS, CLIENT_ID, DEVICE_GRANT_TYPE } from './endpoints';
|
||||
import type { UserInfo } from './config';
|
||||
|
||||
export interface DeviceCode {
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
verificationUriComplete: string;
|
||||
expiresIn: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
export class DeviceAuthError extends Error {}
|
||||
|
||||
/**
|
||||
* IAM reads device-grant parameters from the query string / form body (not
|
||||
* JSON) — see iam controllers/token.go GetOAuthToken, which skips JSON parsing
|
||||
* for the device_code grant. So every IAM call here is form-encoded.
|
||||
*/
|
||||
function form(params: Record<string, string>): string {
|
||||
return new URLSearchParams(params).toString();
|
||||
}
|
||||
|
||||
/** Step 1: ask Hanzo ID to start a device-authorization request (RFC 8628). */
|
||||
export async function requestDeviceCode(): Promise<DeviceCode> {
|
||||
const res = await fetch(`${endpoints.iam}${OAUTH_PATHS.deviceAuthorization}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: form({ client_id: CLIENT_ID, scope: 'openid profile email' }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new DeviceAuthError(`Could not start login (${res.status} ${res.statusText})`);
|
||||
}
|
||||
|
||||
const d = (await res.json()) as {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri?: string;
|
||||
verification_uri_complete?: string;
|
||||
expires_in?: number;
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
const verificationUri = d.verification_uri ?? `${endpoints.iam}/login/oauth/device`;
|
||||
return {
|
||||
deviceCode: d.device_code,
|
||||
userCode: d.user_code,
|
||||
verificationUri,
|
||||
verificationUriComplete:
|
||||
d.verification_uri_complete ?? `${verificationUri}?user_code=${d.user_code}`,
|
||||
expiresIn: d.expires_in ?? 300,
|
||||
interval: d.interval ?? 5,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeviceTokens {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: poll the token endpoint until the user approves, the code expires,
|
||||
* or the request is denied. Honors `authorization_pending` and `slow_down`
|
||||
* backoff signals per the spec. Calls `onTick` once per poll for UI feedback.
|
||||
*/
|
||||
export async function pollForToken(
|
||||
dc: DeviceCode,
|
||||
onTick?: (secondsLeft: number) => void
|
||||
): Promise<DeviceTokens> {
|
||||
let interval = dc.interval;
|
||||
const deadline = Date.now() + dc.expiresIn * 1000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(interval * 1000);
|
||||
onTick?.(Math.max(0, Math.round((deadline - Date.now()) / 1000)));
|
||||
|
||||
const res = await fetch(`${endpoints.iam}${OAUTH_PATHS.token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: form({
|
||||
client_id: CLIENT_ID,
|
||||
device_code: dc.deviceCode,
|
||||
grant_type: DEVICE_GRANT_TYPE,
|
||||
}),
|
||||
});
|
||||
|
||||
// IAM (Casdoor) returns the OAuth body with the matching HTTP status: 200 +
|
||||
// {access_token} on approval, 400 + {error} while pending/denied. We key off
|
||||
// the body so a status-rewriting proxy can't desync us.
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (body.access_token) return { accessToken: body.access_token };
|
||||
|
||||
switch (body.error) {
|
||||
case 'authorization_pending':
|
||||
continue;
|
||||
case 'slow_down':
|
||||
interval += 5;
|
||||
continue;
|
||||
case 'access_denied':
|
||||
throw new DeviceAuthError('Login was denied.');
|
||||
case 'expired_token':
|
||||
throw new DeviceAuthError('Login code expired. Please run `hanzo login` again.');
|
||||
case undefined:
|
||||
throw new DeviceAuthError(`Login failed (${res.status} ${res.statusText})`);
|
||||
default:
|
||||
throw new DeviceAuthError(`Login failed: ${body.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new DeviceAuthError('Login timed out. Please run `hanzo login` again.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the signed-in user's identity. The cloud API's /v1/get-account returns
|
||||
* IAM Claims with the user fields inlined (controllers/account.go GetAccount),
|
||||
* and accepts the device-login bearer token via the auto-signin filter.
|
||||
*/
|
||||
export async function fetchUser(accessToken: string): Promise<UserInfo> {
|
||||
const res = await fetch(`${endpoints.api}/v1/get-account`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new DeviceAuthError(`Could not fetch profile (${res.status} ${res.statusText})`);
|
||||
}
|
||||
const u = (await res.json()) as {
|
||||
id?: string;
|
||||
sub?: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
owner?: string;
|
||||
};
|
||||
return {
|
||||
id: u.id ?? u.sub ?? '',
|
||||
name: u.displayName ?? u.name ?? u.email ?? 'unknown',
|
||||
email: u.email ?? '',
|
||||
...(u.owner ? { org: u.owner } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Read / merge / write JSON config files without clobbering unrelated keys.
|
||||
* Coding tools own these files; we only touch our own slice of them.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function readJson<T extends object>(file: string): T {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8')) as T;
|
||||
} catch {
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJson(file: string, data: unknown, indent = 2): void {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify(data, null, indent), 'utf-8');
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { endpoints } from './endpoints';
|
||||
|
||||
export interface ModelChoice {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Sensible defaults across the providers Hanzo Cloud fronts. */
|
||||
export const FEATURED_MODELS: readonly ModelChoice[] = [
|
||||
{ id: 'claude-opus-4-8', label: 'Claude Opus 4.8 (Anthropic)' },
|
||||
{ id: 'claude-sonnet-4', label: 'Claude Sonnet 4 (Anthropic)' },
|
||||
{ id: 'glm-5.2', label: 'GLM 5.2 (Zhipu)' },
|
||||
{ id: 'gpt-5.4', label: 'GPT-5.4 (OpenAI)' },
|
||||
{ id: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' },
|
||||
{ id: 'kimi-k2.6', label: 'Kimi K2.6 (Moonshot)' },
|
||||
];
|
||||
|
||||
export const DEFAULT_MODEL = 'claude-opus-4-8';
|
||||
|
||||
/** Fetch the live model catalog from Hanzo Cloud (OpenAI-compatible shape). */
|
||||
export async function fetchModels(apiKey: string): Promise<string[]> {
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const version = '1.0.0';
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Cross-platform "is this binary on PATH?" check.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
export function isInstalled(bin: string): boolean {
|
||||
try {
|
||||
execSync(process.platform === 'win32' ? `where ${bin}` : `command -v ${bin}`, {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
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>;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export const claudeCode: CodingTarget = {
|
||||
id: 'claude-code',
|
||||
displayName: 'Claude Code',
|
||||
bin: 'claude',
|
||||
installCommand: 'npm install -g @anthropic-ai/claude-code',
|
||||
|
||||
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,
|
||||
};
|
||||
writeJson(SETTINGS, settings);
|
||||
|
||||
const claudeJson = readJson<{ hasCompletedOnboarding?: boolean }>(CLAUDE_JSON);
|
||||
if (!claudeJson.hasCompletedOnboarding) {
|
||||
writeJson(CLAUDE_JSON, { ...claudeJson, hasCompletedOnboarding: true });
|
||||
}
|
||||
},
|
||||
|
||||
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;
|
||||
writeJson(SETTINGS, settings);
|
||||
},
|
||||
|
||||
status(): TargetStatus {
|
||||
const token = readJson<ClaudeSettings>(SETTINGS).env?.['ANTHROPIC_AUTH_TOKEN'];
|
||||
return {
|
||||
installed: isInstalled(this.bin),
|
||||
configured: typeof token === 'string' && token.length > 0,
|
||||
...(typeof token === 'string' ? { apiKeyMasked: maskKey(token) } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Codex target.
|
||||
*
|
||||
* Codex (the OpenAI CLI) reads ~/.codex/config.toml. A custom provider is an
|
||||
* OpenAI-compatible endpoint declared under [model_providers.<id>], selected by
|
||||
* top-level `model` + `model_provider`. The provider's key is taken from an
|
||||
* environment variable named by `env_key`; we write that var into ~/.hanzo/env
|
||||
* and configure Codex to read HANZO_API_KEY. Hanzo serves /v1/chat/completions
|
||||
* at the API root, so wire_api = "chat" with base_url = <api>/v1.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import { parse, stringify } from 'smol-toml';
|
||||
import { isInstalled } from '../lib/which';
|
||||
import { type CodingTarget, type HanzoCredentials, type TargetStatus, maskKey } from './types';
|
||||
|
||||
const CONFIG = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
const ENV_FILE = path.join(os.homedir(), '.hanzo', 'env');
|
||||
const ENV_KEY = 'HANZO_API_KEY';
|
||||
const PROVIDER_ID = 'hanzo';
|
||||
|
||||
interface CodexConfig {
|
||||
model?: string;
|
||||
model_provider?: string;
|
||||
model_providers?: Record<string, CodexProvider>;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface CodexProvider {
|
||||
name: string;
|
||||
base_url: string;
|
||||
env_key: string;
|
||||
wire_api?: string;
|
||||
}
|
||||
|
||||
export const codex: CodingTarget = {
|
||||
id: 'codex',
|
||||
displayName: 'Codex',
|
||||
bin: 'codex',
|
||||
installCommand: 'npm install -g @openai/codex',
|
||||
|
||||
configure(creds: HanzoCredentials): void {
|
||||
const config = readToml();
|
||||
|
||||
config.model = creds.model;
|
||||
config.model_provider = PROVIDER_ID;
|
||||
config.model_providers = {
|
||||
...config.model_providers,
|
||||
[PROVIDER_ID]: {
|
||||
name: 'Hanzo',
|
||||
base_url: `${creds.apiBase}/v1`,
|
||||
env_key: ENV_KEY,
|
||||
wire_api: 'chat',
|
||||
},
|
||||
};
|
||||
writeToml(config);
|
||||
writeEnvFile(creds.apiKey);
|
||||
},
|
||||
|
||||
unconfigure(): void {
|
||||
const config = readToml();
|
||||
if (config.model_providers) {
|
||||
delete config.model_providers[PROVIDER_ID];
|
||||
if (Object.keys(config.model_providers).length === 0) delete config.model_providers;
|
||||
}
|
||||
if (config.model_provider === PROVIDER_ID) {
|
||||
delete config.model_provider;
|
||||
delete config.model;
|
||||
}
|
||||
writeToml(config);
|
||||
fs.rmSync(ENV_FILE, { force: true });
|
||||
},
|
||||
|
||||
status(): TargetStatus {
|
||||
const config = readToml();
|
||||
const configured = config.model_provider === PROVIDER_ID;
|
||||
const key = readEnvFile();
|
||||
return {
|
||||
installed: isInstalled(this.bin),
|
||||
configured,
|
||||
...(key ? { apiKeyMasked: maskKey(key) } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The env line that makes the key available to Codex. `hanzo login` prints this
|
||||
* so users can add it to their shell rc; sourcing ~/.hanzo/env also works.
|
||||
*/
|
||||
export function codexEnvHint(): string {
|
||||
return `export ${ENV_KEY}="$(cat ${ENV_FILE} 2>/dev/null)"`;
|
||||
}
|
||||
|
||||
function readToml(): CodexConfig {
|
||||
try {
|
||||
return parse(fs.readFileSync(CONFIG, 'utf-8')) as CodexConfig;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeToml(config: CodexConfig): void {
|
||||
fs.mkdirSync(path.dirname(CONFIG), { recursive: true });
|
||||
fs.writeFileSync(CONFIG, stringify(config), 'utf-8');
|
||||
}
|
||||
|
||||
function writeEnvFile(apiKey: string): void {
|
||||
fs.mkdirSync(path.dirname(ENV_FILE), { recursive: true });
|
||||
fs.writeFileSync(ENV_FILE, apiKey, { mode: 0o600 });
|
||||
}
|
||||
|
||||
function readEnvFile(): string | undefined {
|
||||
try {
|
||||
return fs.readFileSync(ENV_FILE, 'utf-8').trim() || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Registry of all coding tools the helper can point at Hanzo.
|
||||
* Add a new tool by implementing CodingTarget and listing it here — nothing
|
||||
* else in the CLI needs to change.
|
||||
*/
|
||||
|
||||
import type { CodingTarget } from './types';
|
||||
import { claudeCode } from './claude-code';
|
||||
import { codex } from './codex';
|
||||
|
||||
export const TARGETS: readonly CodingTarget[] = [claudeCode, codex];
|
||||
|
||||
export function getTarget(id: string): CodingTarget | undefined {
|
||||
return TARGETS.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
export { codexEnvHint } from './codex';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* A CodingTarget is any coding tool the helper can point at Hanzo: it knows how
|
||||
* to detect itself, write the Hanzo provider into its own config format, and
|
||||
* remove it again. One interface, one implementation per tool — orthogonal.
|
||||
*/
|
||||
|
||||
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. */
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface TargetStatus {
|
||||
/** True if the tool's CLI binary is on PATH. */
|
||||
installed: boolean;
|
||||
/** True if this tool is currently pointed at Hanzo. */
|
||||
configured: boolean;
|
||||
/** Masked API key currently in the tool's config, if any. */
|
||||
apiKeyMasked?: string;
|
||||
}
|
||||
|
||||
export interface CodingTarget {
|
||||
/** Stable id used on the command line, e.g. "claude-code". */
|
||||
readonly id: string;
|
||||
/** Human label, e.g. "Claude Code". */
|
||||
readonly displayName: string;
|
||||
/** PATH binary that proves the tool is installed, e.g. "claude". */
|
||||
readonly bin: string;
|
||||
/** npm/script command to install the tool, if installable that way. */
|
||||
readonly installCommand?: string;
|
||||
|
||||
/** Point this tool at Hanzo using the given credentials. */
|
||||
configure(creds: HanzoCredentials): void;
|
||||
/** Remove Hanzo configuration, leaving the tool's other settings intact. */
|
||||
unconfigure(): void;
|
||||
/** Inspect install + configuration state. */
|
||||
status(): TargetStatus;
|
||||
}
|
||||
|
||||
export function maskKey(key: string): string {
|
||||
if (key.length <= 10) return '****';
|
||||
return `${key.slice(0, 6)}…${key.slice(-4)}`;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2024"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts', 'src/cli.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node22',
|
||||
clean: true,
|
||||
dts: false,
|
||||
sourcemap: true,
|
||||
splitting: false,
|
||||
bundle: true,
|
||||
noExternal: [],
|
||||
esbuildOptions(options) {
|
||||
options.banner = {
|
||||
js: '// @hanzo/helper - Hanzo CLI for API access, cloud services, MCP, and plugins',
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user