Files
OmniRoute/bin/cli
Suman 2675b650b5 fix(cli): escape claude args and stop aborting on exit in omniroute launch (#8837)
* fix(cli): escape claude args and stop aborting on exit in omniroute launch

Windows launches go through spawn(..., { shell: true }), which joins argv
with plain spaces and no escaping (Node DEP0190). Any argument containing a
space was split, so `omniroute launch -p "two words"` reached claude as
`-p two` plus stray positionals, and the prompt was silently truncated.

Escape each argument for cmd.exe instead: CRT argv rules first (double the
backslashes preceding a quote, escape embedded quotes, wrap in quotes), then
cmd metacharacters caret-escaped twice. The second pass is required because
claude.cmd is an npm shim that re-parses %* on the way to node; with a single
pass arguments still truncated at the first `&` or `|`.

The command action also called process.exit() on any non-zero exit. That tore
the loop down while the exited child's inherited stdio handles were still
closing and aborted the process with a libuv assertion
(!(handle->flags & UV_HANDLE_CLOSING), src/win/async.c:94, exit 0xC0000409)
instead of returning claude's exit code. Set process.exitCode and let the
loop drain.

Extracts resolveClaudeSpawn() alongside the existing resolveCodexSpawn()
precedent so both the platform choice and the escaping are unit-testable.

Tests: tests/unit/cli/launch-windows-spawn-args.test.ts (new, 7 tests).
Four pure-function tests plus golden strings pin the exact encoding on every
platform; a Windows-only test round-trips argv through a real npm-style .cmd
shim and asserts embedded quotes, `&`, `|`, `%PATH%`, `^`, `!`, a trailing
backslash and an empty string all arrive byte-identical.

Note: bin/cli/commands/launch-codex.mjs carries the identical defect (same
shell:true concatenation, same process.exit) and is left unchanged here.

* docs(changelog): add fragment for #8837

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-28 10:24:54 -03:00
..
2026-06-23 17:06:18 -03:00
2026-06-20 07:09:43 -03:00
2026-06-19 06:49:01 -03:00
2026-06-23 03:08:29 -03:00
2026-05-21 01:29:12 -03:00
2026-06-21 08:56:51 -03:00
2026-05-10 00:55:06 -03:00
2026-05-10 00:55:06 -03:00
2026-05-10 00:55:06 -03:00

bin/cli — OmniRoute CLI internals

This directory contains the CLI runtime, helpers, and commands for the omniroute binary.

Structure

bin/cli/
├── CONVENTIONS.md          ← normative design rules (read this first)
├── README.md               ← this file
├── program.mjs             ← Commander setup — global flags, registerCommands()
├── api.mjs                 ← apiFetch() — all HTTP calls + retry/backoff
├── runtime.mjs             ← withRuntime() — server-first / DB-fallback
├── i18n.mjs                ← t() — i18n helper + locale detection
├── output.mjs              ← emit() — table/json/jsonl/csv + printSuccess/printError
├── io.mjs                  ← ask() / askSecret() — interactive prompts
├── data-dir.mjs            ← resolveDataDir() / resolveStoragePath()
├── sqlite.mjs              ← openOmniRouteDb() — DB bootstrap
├── encryption.mjs          ← encrypt/decrypt credentials
├── provider-catalog.mjs    ← static provider catalog
├── provider-store.mjs      ← DB CRUD for provider_connections
├── provider-test.mjs       ← testProviderApiKey()
├── settings-store.mjs      ← DB CRUD for key_value settings
├── locales/
│   ├── en.json             ← English strings (source of truth, 42+ locales)
│   ├── pt-BR.json          ← Portuguese (Brazil) — fully translated
│   └── {locale}.json       ← 40 additional locales (ar, az, de, es, fr, ja, zh-CN, …)
├── scripts/
│   └── generate-locales.mjs ← scaffold new locale files from config/i18n.json
└── commands/
    ├── setup.mjs
    ├── doctor.mjs
    ├── providers.mjs
    ├── config.mjs          ← includes `config lang get/set/list`
    ├── status.mjs
    ├── logs.mjs
    └── update.mjs

Key helpers

apiFetch(path, opts)api.mjs

All HTTP calls to the OmniRoute server must go through this wrapper.

import { apiFetch } from "./api.mjs";

const res = await apiFetch("/api/health");
if (!res.ok) await res.assertOk(); // throws ApiError with mapped exit code
const data = await res.json();

Options:

  • baseUrl — override base URL (default: OMNIROUTE_BASE_URL env or localhost:20128)
  • apiKey — override API key (default: OMNIROUTE_API_KEY)
  • method, body, headers — standard fetch options
  • timeout — per-attempt ms (default: 30000)
  • retryfalse to disable (default: enabled)
  • retryMax — total attempts (default: 3)
  • verbose — log retry attempts to stderr

withRuntime(fn, opts)runtime.mjs

Provides server-first / DB-fallback transparently.

import { withRuntime } from "./runtime.mjs";

await withRuntime(async (ctx) => {
  if (ctx.kind === "http") {
    const res = await ctx.api("/v1/providers");
    return res.json();
  }
  return ctx.db.prepare("SELECT * FROM provider_connections").all();
});
  • opts.requireServer = true — throws ServerOfflineError (exit 3) if offline
  • opts.preferDb = true — always use DB (skip server check)

t(key, vars)i18n.mjs

Internationalized strings. Catalog loaded from locales/{locale}.json.

import { t } from "./i18n.mjs";

console.log(t("common.serverOffline"));
console.log(t("setup.testFailed", { error: err.message }));

Locale detection order: OMNIROUTE_LANGLC_ALLLC_MESSAGESLANGen.

emit(data, opts)output.mjs

Format-aware output. Reads opts.output to select table/json/jsonl/csv.

import { emit, printError, EXIT_CODES } from "./output.mjs";

emit(providers, { output: opts.output ?? "table" });
printError("Something went wrong");
process.exit(EXIT_CODES.SERVER_OFFLINE);

Locale selection

The CLI displays text in the user's language. Detection order:

  1. --lang <code> flag on the command line
  2. OMNIROUTE_LANG environment variable
  3. System env: LC_ALLLC_MESSAGESLANG
  4. Fallback: en

Set permanently:

omniroute config lang set pt-BR       # saves to ~/.omniroute/.env
omniroute config lang list            # show all 42 available locales
omniroute config lang get             # show currently active locale

One-time override:

omniroute --lang de providers list    # run in German, not persisted
OMNIROUTE_LANG=ja omniroute status    # same effect via env

Adding a new locale: add entry to config/i18n.json, then run:

node bin/cli/scripts/generate-locales.mjs

Adding a new command

  1. Create bin/cli/commands/your-command.mjs
  2. Export registerYourCommand(program) following the Commander pattern
  3. Register in bin/cli/commands/registry.mjs
  4. Add strings to locales/en.json and locales/pt-BR.json
  5. Write test in tests/unit/cli-your-command.test.ts

See CONVENTIONS.md for exit codes, flag naming, output format, and destructive-action rules.