# OmniRoute CLI — Internal Conventions > Status: normative. Source: `_tasks/features-v3.8.0/cli/fase-0-preparacao/0.3-definir-convencoes.md`. > This file is the authoritative reference for every new or migrated CLI command. > If reality diverges from this document, fix the code first; only edit this file > after the discrepancy has been justified in a PR. ## 1. Subcommand style **Standard**: `git`-style nested verbs. ``` omniroute keys add openai sk-xxx omniroute combo switch fastest omniroute memory search "react hooks" ``` **Not allowed**: ``` omniroute --add-key openai sk-xxx # ❌ flag-as-verb omniroute add-key openai sk-xxx # ❌ hyphen at the top level ``` ## 2. Flags - Only `--long` and `-s` shorts (one-letter shorts reserved for very common flags: `-h`, `-v`, `-o`, `-q`, `--no-open`). - Format: `--api-key sk-xxx` (space). `=` accepted for parity but doc uses space. - Naming: kebab-case (`--api-key`, `--non-interactive`, `--max-tokens`). - Booleans: `--no-foo` (negative) and `--foo` (positive). Default `false` unless documented. - Multi-value: repeat the flag (`--header X-A=1 --header X-B=2`). ## 3. Output (`--output`) | Value | Use case | | ------- | -------------------------------------------- | | `table` | default human-readable | | `json` | single JSON object, pretty-printed | | `jsonl` | streamed objects, one per line (logs, lists) | | `csv` | spreadsheet ingestion | Related flags: - `--quiet` / `-q` — suppress headers/spinners (pipe-friendly). - `--no-color` — force ANSI off (auto-detected if `!stdout.isTTY`). Helper: `emit(rows, opts)` from `bin/cli/output.mjs` handles all four formats. ## 4. Exit codes | Code | Meaning | | ----- | --------------------------------- | | `0` | success | | `1` | generic error (uncaught, runtime) | | `2` | invalid argument / misuse | | `3` | server offline (when required) | | `4` | auth / permission (401/403) | | `5` | rate limit / quota (429) | | `124` | timeout | Helper: `exitWith(code, message?)` from `bin/cli/exit.mjs` (added under `output.mjs` if needed) — always uses these constants. **Never** raw `process.exit(N)` in command code. ## 5. HTTP errors + retry/backoff All API calls go through `apiFetch(path, opts)` (`bin/cli/api.mjs`), which: - Reads base URL from `OMNIROUTE_BASE_URL` env or `~/.omniroute/config.json` (active profile). - Injects `Authorization: Bearer ${OMNIROUTE_API_KEY}` when available. - Injects `x-omniroute-cli-token` when applicable (see task 8.12). - Applies a per-attempt timeout (`--timeout 30000`, default 30s). - Maps status → exit code (401→4, 429→5, 5xx→1, etc.). - Never exposes `err.stack` (CLAUDE.md hard rule #12). - Applies exponential backoff with jitter on retryable statuses. ### Retry defaults ```js export const RETRY_DEFAULTS = { maxAttempts: 3, // 1 initial + 2 retries baseMs: 500, maxMs: 8000, // jitter can slightly exceed jitter: true, // ±25% retryableStatuses: [408, 425, 429, 502, 503, 504], retryableErrorCodes: [ "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "EPIPE", ], }; ``` ### Global flags wired - `--retry` (default on) / `--no-retry` - `--retry-max ` (default 3) — total attempts - `--timeout ` (default 30000) — per attempt - `--retry-on ` — extra retryable statuses (e.g. `--retry-on 500`) ### Method semantics - Mutations (`POST`/`PUT`/`DELETE`) retry **only** on idempotent-ish statuses (`502`/`503`/`504`/`408`/network), never `409`/`422`. This avoids duplicate side-effects. - `GET` retries all `RETRY_DEFAULTS.retryableStatuses`. - SSE / streaming does **not** auto-retry (operator decides). - Optional `--idempotency-key ` for extra-safe mutations. ### Status → exit code map | Status | Exit | Retry? | | --------------- | ---- | ------------------------------ | | 200–299 | 0 | n/a | | 400 | 2 | no | | 401 | 4 | no | | 403 | 4 | no | | 404 | 2 | no | | 408 | 124 | **yes** | | 409 | 1 | no (mutations) | | 422 | 2 | no | | 425 | 1 | **yes** | | 429 | 5 | **yes** (respects Retry-After) | | 500 | 1 | configurable (default no) | | 502 / 503 / 504 | 1 | **yes** | | Network errors | 1 | **yes** | | Timeout | 124 | **yes** | ## 6. Internationalization - Every user-facing string goes through `t("module.key", vars)`. - Catalogs live in `bin/cli/locales/{locale}.json` (nested objects). 42 files ship out-of-the-box: `en`, `pt-BR`, and 40 additional locales. 11 locales are scaffold-only (empty `{}`); all keys fall back to `en` automatically. - Detection order: `--lang` flag → `OMNIROUTE_LANG` env → `LC_ALL` → `LC_MESSAGES` → `LANG` → `en`. - Locale persisted via `config lang set ` — saves `OMNIROUTE_LANG` to `~/.omniroute/.env`. - Missing keys return the key itself (no crash). - PRs that add new strings **must** update `en.json` and `pt-BR.json`. Other locale files are best-effort; missing keys silently fall back to `en`. - `normalize()` in `i18n.mjs` validates locale codes via `/^[a-zA-Z0-9-]+$/` to prevent path traversal — never pass raw filesystem paths. - Canonical locale list: `config/i18n.json` — source of truth used by both CLI and dashboard i18n pipelines. ### Adding a new locale file 1. Add entry to `config/i18n.json` with `code`, `english`, `native`, `flag`. 2. Run `node bin/cli/scripts/generate-locales.mjs` — creates `bin/cli/locales/{code}.json`. 3. Fill in translations (or leave as `{}` for en-fallback scaffold). 4. The pre-commit hook `check-cli-i18n` will verify all `t()` keys exist in `en.json`. ## 7. Logs / output channels - `stdout` — useful output (parseable when `--output json|jsonl|csv`). - `stderr` — progress, warnings, errors, spinners. - `--verbose` / `-V` — extra detail on stderr. - `--debug` — stack traces, request bodies (dev-mode only; redacts secrets). ## 8. Server-first / DB-fallback Single helper: ```js import { withRuntime } from "./runtime.mjs"; await withRuntime(async ({ kind, api, db }) => { if (kind === "http") return api("/api/combos", { retry: false, timeout: 5000, acceptNotOk: true }); return db.combos.getCombos(); }); ``` - `kind: "http"` when server is up (preferred). `api` is `apiFetch` bound to the current profile/base-URL. - `kind: "db"` when server is offline. `db` exposes typed module exports: - `db.combos` → `src/lib/db/combos.ts` (getCombos, getComboByName, createCombo, deleteComboByName, setActiveCombo, …) - `db.recovery` → `src/lib/db/recovery.ts` (countEncryptedCredentials, resetEncryptedColumns) - Mutations that require server **must** error with exit code `3` when the server is down, never silently fall back. - **Never** write raw SQL in commands — always go through `src/lib/db/` modules. The Semgrep rule at `.semgrep/rules/cli-no-sqlite.yaml` enforces this at commit time. ## 9. Audit of destructive actions Commands that mutate state (delete, reset, `--force`) **must**: - Ask for interactive confirmation (skipped with `--yes`). - POST to `/api/compliance/audit-log` when the server is up. - Support `--dry-run` (preview without effect). ## 10. Secrets - **Never** log secrets. Mask as `sk-***-xxx` via `maskSecret()` from `bin/cli/output.mjs`. - **Never** accept a secret via positional without warning. Prefer: - env (`OMNIROUTE_*_API_KEY`) - stdin (`--api-key-stdin`) - interactive `askSecret()` (echo off — already implemented in `io.mjs`) - Secrets must not appear in `--verbose` / `--debug` output. ## 11. Testing baseline - Every new command ships with at least one smoke test (happy path + one error path). - Use `tests/unit/cli-*.test.ts` naming. Prefer `node:test` for CLI suites (no extra deps). - Coverage target: ≥60% for `bin/cli/commands/`, ≥75% for `bin/cli/` overall after Fase 8. ## 12. References - CLAUDE.md hard rules — especially #11 (publicCreds), #12 (error sanitization), #13 (shell injection). - `docs/security/ERROR_SANITIZATION.md` — the only acceptable error shapes. - `tests/unit/cli-tools-i18n.test.ts` — current i18n infrastructure (pre-`t()`). - Commander.js docs — Options & subcommand patterns.