Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 89 additions & 108 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,145 +1,126 @@
# @kenarihq/cli

The official kenari CLI. It switches Claude Code and Codex to the [kenari](https://kenari.id)
gateway and back. When a tool is switched, its requests route through kenari and bill your
kenari wallet. Restoring puts the tool back on its original provider.

## Install and run

Run it once without installing:

```
npx @kenarihq/cli
```

Or install it globally and use the `kenari` command:
Route native and Kenari models inside one Claude Code or Codex CLI session.
Plain `claude` and `codex` stay native. Kenari routing only applies when launched
through the wrapper:

```bash
kenari claude [args...]
kenari codex [args...]
```
npm i -g @kenarihq/cli
kenari
```

Node 18 or newer is required.

## Quick start
Each launch starts a private router on `127.0.0.1` with a random port, starts the
original CLI with process-scoped overrides, forwards terminal I/O and signals,
then stops the router. No provider setting or credential is written into Claude
Code or Codex configuration.

Sign in and store an API key:
## Install

```
```bash
npm install -g @kenarihq/cli
kenari login
kenari configure
```

This opens your browser to approve the CLI, then stores the key for you, no copy-pasting a key
by hand. On a machine with no browser, or where the loopback callback is blocked (a remote SSH
box), use `kenari login --paste` instead: it prints a URL to open elsewhere and asks you to
paste back a short code. Add `--no-browser` to either mode to print the URL instead of opening
it automatically.
Node.js 18 or newer is required.

Point Claude Code at kenari:

```
kenari use claude
```

If no key is stored yet, this asks for one (`kn-...`, from https://kenari.id/keys) directly; run
`kenari login` first if you would rather authenticate that way. It then lets you pick a kenari
model for each Claude slot (opus, sonnet, haiku).

Check what each tool points at:

```
kenari status
```

Put Claude Code back on its original provider:
## Commands

```
kenari use claude default
```text
kenari configure [claude|codex]
kenari reset [claude|codex]
kenari claude [args...]
kenari codex [args...]
kenari status [--check] [--json]
kenari models [--json]
kenari login [--no-browser] [--paste] [--stdin]
kenari logout
kenari help
```

## Commands
`kenari use` and `kenari key` were removed. Routing is process-scoped and
credentials are managed by `login` and `logout`.

| Command | What it does |
| --- | --- |
| `kenari` | Interactive switcher. Lists installed tools and lets you pick a target. |
| `kenari login` | Sign in through the browser (PKCE) and store an API key. |
| `kenari login --no-browser` | Print the approval URL instead of opening it. |
| `kenari login --paste` | Paste-code mode, for a blocked loopback or a remote box. |
| `kenari use <tool>` | Switch `<tool>` to kenari. Prompts for models unless you pass flags. |
| `kenari use <tool> default` | Restore `<tool>` to the provider it used before the switch. |
| `kenari status` | Show whether each tool points at kenari or its default. |
| `kenari models` | List kenari models with input and output prices. |
| `kenari key set` | Store a kenari API key (prompted, or `--stdin` to pipe it in). |
| `kenari key show` | Print the stored key, masked. |
| `kenari key delete` | Remove the stored key. |
## Routing configuration

Tools are `claude` (Claude Code) and `codex` (Codex).
Claude Code supports these roles:

Model flags for `kenari use`:
- `main`
- `opus`
- `sonnet`
- `haiku`
- `fable`
- `subagents`

- Claude has three slots: `--opus <model> --sonnet <model> --haiku <model>`.
- Codex has one slot: `--model <model>`.
Codex supports these roles:

Pass any of these to skip the interactive prompt. A model that is not in the kenari catalog is
rejected. Run `kenari models` to see valid ids.
- `main`
- `review`
- `subagents`

## How login works
Every role can stay `native` or select a fixed `kenari/<model-id>`. Codex main
also supports `picker`. Codex review and subagents also support `inherit`.
Native is always the default.

`kenari login` opens `https://kenari.id/cli-auth` in your browser with a PKCE challenge and
binds a one-shot local server on a random loopback port. Approving in the browser redirects
back to that local server with a single-use code; the CLI exchanges it for an API key over
`https://kenari.id/api/cli-auth/token` and stores the key. The verifier never leaves your
machine, and the flow times out after 5 minutes if nothing comes back. `kenari login --paste`
skips the local server entirely: it shows a short code in the browser that you type back into
the terminal, for machines where a browser cannot reach a loopback port on your machine (a
remote SSH box) or where you would rather not open one automatically.
Automation must set every role:

## How switching works
```bash
kenari configure claude \
--main native \
--opus native \
--sonnet kenari/glm-5-2 \
--haiku native \
--fable native \
--subagents native \
--yes

A switch writes the env block in `~/.claude/settings.json` and the provider entry in
`~/.codex/config.toml`, and records the previous values first. Restore puts those previous
values back and never touches keys it does not own. If you hand-edit an owned key while a tool
is switched, restore leaves that key alone and tells you which ones it skipped. Config file
locations follow `CLAUDE_CONFIG_DIR` and `CODEX_HOME` when those are set.
kenari configure codex \
--main picker \
--review inherit \
--subagents kenari/gpt-5.4 \
--yes
```

## Key storage
Unprefixed model IDs always route to the native provider. `kenari/` model IDs
always route to Kenari. Routing never falls back between providers.

The API key is stored in plaintext at `~/.kenari/credentials.json`. On POSIX systems the file
is written with mode 0600 (owner read and write only). On Windows the file is protected only by
your user profile ACLs. Treat the key like a password: anyone who can read the file can spend
your wallet. Remove it any time with `kenari key delete`.
## Files

## Minimum tested versions
Kenari owns these files:

The versions below are the tool releases this CLI has been verified against.
```text
~/.kenari/config.json
~/.kenari/credentials.json
~/.kenari/model-cache.json
~/.kenari/state.json
```

| Tool | Minimum tested version | Status |
| --- | --- | --- |
| Claude Code | 2.1.206 | verified end to end (switch, tool loop through kenari, restore) |
| Codex | 0.144.1 | verified end to end (switch, text and tool loop through kenari, restore) |
The directory uses mode `0700` and files use mode `0600` on POSIX systems.
Writes are atomic. The Kenari credential is stored only in
`credentials.json`, never in Claude Code settings, Codex configuration,
generated model catalogs, command arguments, or logs.

**Codex wire.** Codex removed the `chat/completions` wire protocol and now requires the
OpenAI `responses` API. The kenari gateway serves `/v1/responses`, so the Codex adapter
writes `wire_api = "responses"`. A switched Codex runs against kenari for both plain
prompts and tool loops.
`KENARI_HOME`, `CLAUDE_CONFIG_DIR`, and `CODEX_HOME` override their matching
directories. `KENARI_BASE_URL` overrides the Kenari gateway. HTTP gateways are
rejected unless `KENARI_ALLOW_HTTP=1` is set for local development.

## Troubleshooting
## Catalog and offline behavior

**"is a symlink. Refusing to replace it with a regular file."** The CLI will not overwrite a
config file that is a symlink, because replacing it would break the link. Point the CLI at the
real file, or edit the config by hand.
`kenari configure` fetches and validates the model catalog. The cache is fresh
for 24 hours.

**"another kenari process is running."** Only one `kenari` command can modify config at a time.
Wait for the other run to finish, then retry. If no other run exists, a stale lock in
`~/.kenari/lock` is cleared automatically after a few seconds.
- Native-only configuration works without login or catalog access.
- A stale valid cache can be used when refresh fails.
- A required Kenari route without a valid cache or credential fails closed.
- A removed or unknown Kenari model fails closed.
- `kenari status` is offline. `kenari status --check` runs bounded network
checks.

**"kenari rejected the API key (HTTP 401)."** The stored key is missing, wrong, or revoked. Run
`kenari login` to get a fresh one, or grab one at https://kenari.id/keys and run `kenari key
set`.
## Reset and logout

**"login timed out, run `kenari login` again."** Nobody approved the browser page within 5
minutes. Run it again and approve promptly, or use `kenari login --paste` if the browser cannot
reach your machine's loopback port.
`kenari reset` removes routing choices without changing native tool
configuration or login state. `kenari logout` removes only the Kenari
credential. Native routes keep working after logout.

## License

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@kenarihq/cli",
"version": "0.2.0",
"description": "Official kenari CLI. Switch Claude Code and Codex to the kenari gateway.",
"version": "0.3.0",
"description": "Official kenari CLI. Route Claude Code and Codex models per process.",
"license": "MIT",
"type": "module",
"bin": { "kenari": "bin/kenari.js" },
Expand Down
107 changes: 3 additions & 104 deletions src/adapters/claude.js
Original file line number Diff line number Diff line change
@@ -1,114 +1,13 @@
import fs from 'node:fs';
import { claudeConfigDir, claudeSettingsPath, gatewayBase } from '../paths.js';
import { readJson, writeJson, getToolState, setToolState, clearToolState } from '../store.js';

const SLOT_ENV = {
opus: 'ANTHROPIC_DEFAULT_OPUS_MODEL',
sonnet: 'ANTHROPIC_DEFAULT_SONNET_MODEL',
haiku: 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
};

function ownedValues(mapping, key) {
return {
ANTHROPIC_BASE_URL: gatewayBase(),
ANTHROPIC_AUTH_TOKEN: key,
// Pin the main loop too: without this, a session on an Anthropic-native
// model (for example claude-fable-5) sends that id through the gateway
// untouched, and if the gateway catalogs it, it bills at premium price.
ANTHROPIC_MODEL: mapping.sonnet,
[SLOT_ENV.opus]: mapping.opus,
[SLOT_ENV.sonnet]: mapping.sonnet,
[SLOT_ENV.haiku]: mapping.haiku,
};
}
import { claudeConfigDir, claudeSettingsPath } from '../paths.js';

export default {
id: 'claude',
name: 'Claude Code',
slots: [
{ id: 'opus', label: 'Opus slot (strongest)', defaultModel: 'glm-5-2' },
{ id: 'sonnet', label: 'Sonnet slot (daily driver)', defaultModel: 'kimi-k2-7-code' },
{ id: 'haiku', label: 'Haiku slot (fast/background)', defaultModel: 'deepseek-v4-flash' },
],

detect() {
return { installed: fs.existsSync(claudeConfigDir()), configPath: claudeSettingsPath() };
},

status() {
const settings = readJson(claudeSettingsPath()) || {};
const env = settings.env || {};
const onKenari = env.ANTHROPIC_BASE_URL === gatewayBase()
&& typeof env.ANTHROPIC_AUTH_TOKEN === 'string'
&& env.ANTHROPIC_AUTH_TOKEN.startsWith('kn-');
const notes = [];
const state = getToolState('claude');
if (state && !onKenari) notes.push('switch state exists but settings.json no longer points at kenari (hand-edited?)');
if (!state && onKenari) notes.push('points at kenari but no switch state (configured outside this CLI)');
return {
provider: onKenari ? 'kenari' : 'default',
mapping: onKenari ? {
opus: env[SLOT_ENV.opus], sonnet: env[SLOT_ENV.sonnet], haiku: env[SLOT_ENV.haiku],
} : null,
notes,
installed: fs.existsSync(claudeConfigDir()),
configPath: claudeSettingsPath(),
};
},

apply(mapping, key) {
const file = claudeSettingsPath();
const existing = readJson(file);
const settings = existing || {};
const fileCreated = existing === null;
const containerCreated = !('env' in settings);
const env = settings.env || {};
const values = ownedValues(mapping, key);
const prior = getToolState('claude');
const keys = {};
for (const [k, v] of Object.entries(values)) {
const before = prior?.keys?.[k] !== undefined
? prior.keys[k].before
: (k in env ? env[k] : null);
keys[k] = { before, applied: v };
}
setToolState('claude', {
fileCreated: prior?.fileCreated ?? fileCreated,
containerCreated: prior?.containerCreated ?? containerCreated,
keys,
});
settings.env = { ...env, ...values };
writeJson(file, settings);
},

restore() {
const state = getToolState('claude');
if (!state) return { restored: false, conflicts: [] };
const file = claudeSettingsPath();
const settings = readJson(file) || {};
const env = settings.env || {};
const conflicts = [];
const remaining = {};
for (const [k, rec] of Object.entries(state.keys)) {
const cur = k in env ? env[k] : null;
if (cur === rec.applied) {
if (rec.before === null) delete env[k];
else env[k] = rec.before;
} else if (cur === rec.before) {
// Already back at its baseline (user resolved a prior conflict by hand):
// nothing to revert, and not a conflict, so a repeat restore can finish.
} else {
conflicts.push(`${k}: changed by hand, left as-is`);
remaining[k] = rec;
}
}
settings.env = env;
if (state.containerCreated && Object.keys(env).length === 0) delete settings.env;
if (state.fileCreated && Object.keys(settings).length === 0) {
try { fs.rmSync(file); } catch {}
} else {
writeJson(file, settings);
}
if (conflicts.length === 0) clearToolState('claude');
else setToolState('claude', { ...state, keys: remaining });
return { restored: true, conflicts };
},
};
Loading
Loading