mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
feat(cli): relay-like CLI closure — target manifest, Codex TOML, Gemini launcher, guards
- canonical executable manifest (bin/cli/cli-manifest.mjs): run/configure/completion derive targets, aliases and --model wiring from one table; drift test cross-checks manifest x cliRuntime x UI catalog (tests/unit/cli/cli-manifest-drift.test.ts) - dashboard Codex generator converged to ~/.codex/config.toml (modern Codex v0.137+, verified against codex-cli 0.147.0): conservative merge, env_key auth (key never written), refuses invalid TOML, reports legacy config.yaml as migration note - omniroute run gemini: launcher over OmniRoute's /v1beta surface via GOOGLE_GEMINI_BASE_URL + isolated GEMINI_CLI_HOME forcing gemini-api-key auth (contract proven against @google/gemini-cli 0.50.0); ACP registration kept distinct - opt-in real smoke harness for upstream CLIs (RUN_CLI_SMOKE=1, credential by env NAME, redacted output): tests/integration/upstream-cli-smoke.int.test.ts - container-guard homologation for POST /api/cli-tools/apply (422 in container, dry-run preview allowed, host write passes) + docs; guard untouched - typecheck: omniglyphAdapter union narrowing, usageTracking typed signatures (UsageLike, no any), models.ts isValidModel params — typecheck:core and typecheck:noimplicit:core now clean - relay core (prior session of this effort): omniroute run for 6 CLIs, configure picker with per-context favorites/recents, contexts with optional keychain + 0600 fallback, provider CRUD with recursive redaction, completion updates, docs
This commit is contained in:
@@ -800,6 +800,13 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode).
|
||||
# OMNIROUTE_CONTEXT=
|
||||
|
||||
# Disable the optional OS keychain backend for CLI remote-context credentials.
|
||||
# When enabled, context tokens stay in config.json with mode 0600 and the CLI
|
||||
# prints a one-time fallback warning. Useful for deliberate headless/container
|
||||
# operation; leave unset to use keytar when the native backend is available.
|
||||
# Used by: bin/cli/contexts.mjs.
|
||||
# OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED=0
|
||||
|
||||
# Enforce scope-based access control on MCP tool calls.
|
||||
# Used by: open-sse/mcp-server/server.ts — rejects calls outside allowed scopes.
|
||||
# OMNIROUTE_MCP_ENFORCE_SCOPES=false
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -288,3 +288,6 @@ docker-compose.yml.bak
|
||||
|
||||
# CLI local cache/state
|
||||
.playwright-cli
|
||||
|
||||
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
|
||||
/.sandbox/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs";
|
||||
import { resolveActiveContext } from "./contexts.mjs";
|
||||
import { resolveActiveContext, resolveActiveContextAsync } from "./contexts.mjs";
|
||||
|
||||
export const RETRY_DEFAULTS = Object.freeze({
|
||||
maxAttempts: 3,
|
||||
@@ -77,7 +77,7 @@ export async function buildHeaders(opts) {
|
||||
let auth = explicitKey;
|
||||
if (!auth) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
const ctx = await resolveActiveContextAsync(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
auth = ctx?.accessToken || ctx?.apiKey || null;
|
||||
} catch {
|
||||
// No context credential available — fall through to the ambient fallback.
|
||||
|
||||
138
bin/cli/cli-manifest.mjs
Normal file
138
bin/cli/cli-manifest.mjs
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Canonical executable manifest for the OmniRoute CLI command surfaces.
|
||||
*
|
||||
* One entry per canonical target id. `run.mjs`, `configure.mjs` and
|
||||
* `completion.mjs` derive their target lists, alias resolution and model-flag
|
||||
* wiring from this table instead of keeping private copies, so a new target
|
||||
* (or a renamed alias) is declared exactly once.
|
||||
*
|
||||
* The server-side runtime catalog (`src/shared/services/cliRuntime.ts`) stays
|
||||
* the source of truth for binaries, config paths and health checks; the drift
|
||||
* test `tests/unit/cli/cli-manifest-drift.test.ts` asserts the two worlds and
|
||||
* every consumer surface stay in sync.
|
||||
*
|
||||
* Capability semantics:
|
||||
* - `run`: launchable through `omniroute run <target>`.
|
||||
* - `configure`: supported by the `omniroute configure <target>` picker.
|
||||
* - `runModel`: how `run` injects `--model` for the target (`null` when the
|
||||
* model travels via env/provider args instead of a CLI flag).
|
||||
*/
|
||||
|
||||
export const CLI_TARGET_MANIFEST = Object.freeze({
|
||||
claude: Object.freeze({
|
||||
description: "Claude Code",
|
||||
aliases: Object.freeze(["claude-code", "cc", "anthropic"]),
|
||||
run: true,
|
||||
configure: true,
|
||||
runModel: null, // injected via ANTHROPIC_MODEL env by the launcher
|
||||
}),
|
||||
codex: Object.freeze({
|
||||
description: "OpenAI Codex CLI",
|
||||
aliases: Object.freeze(["codex-cli", "openai-codex", "openai"]),
|
||||
run: true,
|
||||
configure: true,
|
||||
runModel: null, // injected via -c model_providers.omniroute.* args
|
||||
}),
|
||||
aider: Object.freeze({
|
||||
description: "Aider",
|
||||
aliases: Object.freeze([]),
|
||||
run: true,
|
||||
configure: true,
|
||||
runModel: Object.freeze({ flag: "--model", prefix: "openai/" }),
|
||||
}),
|
||||
goose: Object.freeze({
|
||||
description: "Goose",
|
||||
aliases: Object.freeze(["goose-cli"]),
|
||||
run: true,
|
||||
configure: true,
|
||||
runModel: null, // injected via GOOSE_MODEL env
|
||||
}),
|
||||
opencode: Object.freeze({
|
||||
description: "OpenCode",
|
||||
aliases: Object.freeze(["open-code"]),
|
||||
run: true,
|
||||
configure: true,
|
||||
runModel: Object.freeze({ flag: "--model", prefix: "omniroute/" }),
|
||||
}),
|
||||
qwen: Object.freeze({
|
||||
description: "Qwen Code",
|
||||
aliases: Object.freeze(["qwen-code"]),
|
||||
run: true,
|
||||
configure: true,
|
||||
runModel: Object.freeze({ flag: "--model", prefix: "", required: true }),
|
||||
}),
|
||||
gemini: Object.freeze({
|
||||
// Launch contract verified against @google/gemini-cli 0.50.0:
|
||||
// GOOGLE_GEMINI_BASE_URL points the SDK at OmniRoute's /v1beta surface,
|
||||
// GEMINI_API_KEY + isolated GEMINI_CLI_HOME (settings selectedType
|
||||
// "gemini-api-key") force API-key auth over any stored OAuth session.
|
||||
description: "Google Gemini CLI",
|
||||
aliases: Object.freeze(["gemini-cli"]),
|
||||
run: true,
|
||||
configure: false,
|
||||
runModel: Object.freeze({ flag: "--model", prefix: "" }),
|
||||
}),
|
||||
cline: Object.freeze({
|
||||
description: "Cline",
|
||||
aliases: Object.freeze([]),
|
||||
run: false,
|
||||
configure: true,
|
||||
runModel: null,
|
||||
}),
|
||||
continue: Object.freeze({
|
||||
description: "Continue",
|
||||
aliases: Object.freeze(["cn"]),
|
||||
run: false,
|
||||
configure: true,
|
||||
runModel: null,
|
||||
}),
|
||||
kilo: Object.freeze({
|
||||
description: "Kilo Code",
|
||||
aliases: Object.freeze(["kilocode", "kilo-code", "kilo_cli"]),
|
||||
run: false,
|
||||
configure: true,
|
||||
runModel: null,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* List canonical target ids, optionally filtered by capability
|
||||
* (`"run"` or `"configure"`). Order follows manifest declaration order.
|
||||
*/
|
||||
export function listManifestTargets(capability) {
|
||||
return Object.entries(CLI_TARGET_MANIFEST)
|
||||
.filter(([, entry]) => !capability || entry[capability])
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied target (canonical id or alias) to its canonical id.
|
||||
* Returns `undefined` when the target is unknown or lacks the capability.
|
||||
*/
|
||||
export function resolveManifestTarget(rawTarget, capability) {
|
||||
const normalized = String(rawTarget || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) {
|
||||
if (id === normalized || entry.aliases.includes(normalized)) {
|
||||
if (capability && !entry[capability]) return undefined;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Model CLI-flag arguments for a `run` target, derived from the manifest. */
|
||||
export function manifestModelArgs(targetId, model) {
|
||||
if (!model) return [];
|
||||
const spec = CLI_TARGET_MANIFEST[targetId]?.runModel;
|
||||
if (!spec) return [];
|
||||
const value = spec.prefix && !model.startsWith(spec.prefix) ? `${spec.prefix}${model}` : model;
|
||||
return [spec.flag, value];
|
||||
}
|
||||
|
||||
/** Whether a `run` target refuses to launch without an explicit model. */
|
||||
export function manifestRequiresModel(targetId) {
|
||||
return Boolean(CLI_TARGET_MANIFEST[targetId]?.runModel?.required);
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import { homedir } from "node:os";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
import { listManifestTargets } from "../cli-manifest.mjs";
|
||||
|
||||
// Target lists shared with `omniroute run` / `omniroute configure` — always
|
||||
// derived from the canonical manifest so the completion scripts cannot drift.
|
||||
const RUN_TARGET_WORDS = listManifestTargets("run").join(" ");
|
||||
const CONFIGURE_TARGET_WORDS = listManifestTargets("configure").join(" ");
|
||||
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h
|
||||
|
||||
@@ -129,6 +135,14 @@ _omniroute() {
|
||||
'completion:Shell completion'
|
||||
'memory:Manage memory store'
|
||||
'skills:Manage skills'
|
||||
'connect:Connect to a local or remote OmniRoute server'
|
||||
'contexts:Manage local and remote server contexts'
|
||||
'configure:Configure a supported AI CLI'
|
||||
'launch:Launch an AI CLI through OmniRoute'
|
||||
'launch-codex:Launch Codex through OmniRoute'
|
||||
'run:Run a supported AI CLI through OmniRoute'
|
||||
'runtime:Inspect CLI runtime capabilities'
|
||||
'repair:Repair native runtime dependencies'
|
||||
)
|
||||
|
||||
_arguments -C \\
|
||||
@@ -153,7 +167,7 @@ _omniroute() {
|
||||
local -a providers
|
||||
providers=($(_omniroute_get_cache providers))
|
||||
_describe 'provider' providers ;;
|
||||
*) _arguments '1:subcommand:(list add remove test)' ;;
|
||||
*) _arguments '1:subcommand:(available list test test-all validate rotate status add import auth remove edit metrics metric)' ;;
|
||||
esac ;;
|
||||
chat|stream)
|
||||
_arguments \\
|
||||
@@ -165,6 +179,12 @@ _omniroute() {
|
||||
_arguments '1:resource:(combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience)' ;;
|
||||
completion) _arguments '1:subcommand:(zsh bash fish install refresh)' ;;
|
||||
config) _arguments '1:subcommand:(list get set validate contexts)' ;;
|
||||
contexts) _arguments '1:subcommand:(list add use current show remove rename export import migrate)' ;;
|
||||
configure) _arguments '1:target:(${CONFIGURE_TARGET_WORDS})' ;;
|
||||
run) _arguments '1:target:(${RUN_TARGET_WORDS})' ;;
|
||||
connect) _arguments '1:host:' ;;
|
||||
launch|launch-codex) _arguments '--remote[Use a remote server]' '--context[Context name]:' '--model[Model ID]:' ;;
|
||||
runtime) _arguments '1:subcommand:(check repair clean)' ;;
|
||||
*) ;;
|
||||
esac
|
||||
case $state in
|
||||
@@ -208,15 +228,19 @@ _omniroute() {
|
||||
COMPREPLY=()
|
||||
cur="\${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
||||
cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills run"
|
||||
cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills connect contexts configure launch launch-codex run runtime repair"
|
||||
|
||||
case "\${prev}" in
|
||||
combo) COMPREPLY=($(compgen -W "list switch create delete show suggest" -- "\${cur}")); return 0 ;;
|
||||
keys) COMPREPLY=($(compgen -W "add list remove regenerate revoke reveal usage" -- "\${cur}")); return 0 ;;
|
||||
providers) COMPREPLY=($(compgen -W "available list test test-all" -- "\${cur}")); return 0 ;;
|
||||
providers) COMPREPLY=($(compgen -W "available list test test-all validate rotate status add import auth remove edit metrics metric" -- "\${cur}")); return 0 ;;
|
||||
config) COMPREPLY=($(compgen -W "list get set validate contexts" -- "\${cur}")); return 0 ;;
|
||||
completion) COMPREPLY=($(compgen -W "zsh bash fish install refresh" -- "\${cur}")); return 0 ;;
|
||||
open) COMPREPLY=($(compgen -W "combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience" -- "\${cur}")); return 0 ;;
|
||||
contexts) COMPREPLY=($(compgen -W "list add use current show remove rename export import migrate" -- "\${cur}")); return 0 ;;
|
||||
configure) COMPREPLY=($(compgen -W "${CONFIGURE_TARGET_WORDS}" -- "\${cur}")); return 0 ;;
|
||||
run) COMPREPLY=($(compgen -W "${RUN_TARGET_WORDS}" -- "\${cur}")); return 0 ;;
|
||||
runtime) COMPREPLY=($(compgen -W "check repair clean" -- "\${cur}")); return 0 ;;
|
||||
--model)
|
||||
local models
|
||||
models=$(_omniroute_get_cache models)
|
||||
@@ -242,7 +266,7 @@ function generateFishScript() {
|
||||
return `# OmniRoute CLI fish completion (dynamic)
|
||||
complete -c omniroute -f
|
||||
|
||||
set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills update test run
|
||||
set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills connect contexts configure launch launch-codex update test run runtime repair
|
||||
|
||||
for cmd in $commands
|
||||
complete -c omniroute -n '__fish_is_nth_token 1' -a $cmd
|
||||
@@ -251,10 +275,14 @@ end
|
||||
# Subcommands
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list switch create delete show suggest'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'add list remove regenerate revoke reveal usage'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all validate rotate status add import auth remove edit metrics metric'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from config' -a 'list get set validate contexts'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish install refresh'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from open' -a 'combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from contexts' -a 'list add use current show remove rename export import migrate'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from configure' -a '${CONFIGURE_TARGET_WORDS}'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from run' -a '${RUN_TARGET_WORDS}'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from runtime' -a 'check repair clean'
|
||||
|
||||
# Dynamic completions from cache (requires python3)
|
||||
function __omniroute_cache_get
|
||||
|
||||
@@ -2,9 +2,17 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { loadContexts, resolveActiveContext } from "../contexts.mjs";
|
||||
import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
|
||||
import {
|
||||
getModelPreferenceState,
|
||||
loadModelPreferences,
|
||||
rankPreferredModels,
|
||||
recordModelPreference,
|
||||
} from "../model-preferences.mjs";
|
||||
import { listManifestTargets, resolveManifestTarget } from "../cli-manifest.mjs";
|
||||
|
||||
/**
|
||||
* `omniroute configure <cli>` — interactive provider+model picker that writes a
|
||||
@@ -14,11 +22,80 @@ import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
|
||||
* are in remote mode (`omniroute connect ...`) you pick from the remote server's
|
||||
* live models and the profile is written on THIS machine.
|
||||
*
|
||||
* v1 targets the Codex CLI (writes ~/.codex/<name>.config.toml). The credential
|
||||
* is referenced by env var (OMNIROUTE_API_KEY) — never written to disk.
|
||||
* Codex keeps its profile-specific TOML files. Other targets delegate to their
|
||||
* existing setup-* recipe after the same provider/model selection, so the
|
||||
* picker remains a read-only orchestration layer and does not duplicate config
|
||||
* merge logic.
|
||||
*/
|
||||
|
||||
const SUPPORTED = ["codex"];
|
||||
const SUPPORTED = listManifestTargets("configure");
|
||||
|
||||
export const SETUP_MODULES = {
|
||||
claude: { module: "./setup-claude.mjs", exportName: "runSetupClaudeCommand" },
|
||||
opencode: { module: "./setup-opencode.mjs", exportName: "runSetupOpencodeCommand" },
|
||||
qwen: { module: "./setup-qwen.mjs", exportName: "runSetupQwenCommand" },
|
||||
aider: { module: "./setup-aider.mjs", exportName: "runSetupAiderCommand" },
|
||||
goose: { module: "./setup-goose.mjs", exportName: "runSetupGooseCommand" },
|
||||
cline: { module: "./setup-cline.mjs", exportName: "runSetupClineCommand" },
|
||||
continue: { module: "./setup-continue.mjs", exportName: "runSetupContinueCommand" },
|
||||
kilo: { module: "./setup-kilo.mjs", exportName: "runSetupKiloCommand" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Materialize the active server before delegating to a setup recipe.
|
||||
*
|
||||
* `apiFetch` knows how to prefer a named context over an ambient
|
||||
* `OMNIROUTE_API_KEY`, but the older setup modules receive plain options and
|
||||
* resolve those themselves. Passing the resolved URL/key here keeps the
|
||||
* picker and the delegated recipe on the same local/remote target, including
|
||||
* Claude Code which predates context-aware setup resolution.
|
||||
*/
|
||||
export function resolveConfigureTargetOptions(opts = {}) {
|
||||
const resolved = { ...opts };
|
||||
const ambientKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
const explicitRemote = opts.remote || opts.baseUrl;
|
||||
let context;
|
||||
try {
|
||||
context = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
} catch {
|
||||
// A missing/corrupt context file should retain the normal local fallback.
|
||||
}
|
||||
|
||||
if (!explicitRemote) {
|
||||
const localDefault = `http://localhost:${opts.port || process.env.PORT || "20128"}`;
|
||||
const contextBase = String(context?.baseUrl || "").replace(/\/+$/, "");
|
||||
if (contextBase && contextBase !== localDefault) {
|
||||
resolved.remote = contextBase;
|
||||
} else if (opts.port) {
|
||||
resolved.remote = localDefault;
|
||||
}
|
||||
} else if (!resolved.remote && resolved.baseUrl) {
|
||||
resolved.remote = resolved.baseUrl;
|
||||
}
|
||||
|
||||
const contextKey = context?.accessToken || context?.apiKey;
|
||||
if (contextKey && (!opts.apiKey || opts.apiKey === ambientKey)) {
|
||||
resolved.apiKey = contextKey;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function listConfigureTargets() {
|
||||
return [...SUPPORTED];
|
||||
}
|
||||
|
||||
export { getModelPreferenceState, rankPreferredModels };
|
||||
|
||||
function preferenceContextName(opts = {}) {
|
||||
if (opts.context || process.env.OMNIROUTE_CONTEXT) {
|
||||
return String(opts.context || process.env.OMNIROUTE_CONTEXT);
|
||||
}
|
||||
try {
|
||||
return String(loadContexts().currentContext || "default");
|
||||
} catch {
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive a short, filesystem-safe profile name from a model id. */
|
||||
export function profileNameFromModel(modelId) {
|
||||
@@ -80,8 +157,15 @@ async function configureCodex(modelId, ctxWindow, opts) {
|
||||
toolLabel: "Codex",
|
||||
hostCommand: "omniroute configure codex",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
dryRun: Boolean(opts.dryRun ?? opts["dry-run"]),
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
if (opts.dryRun ?? opts["dry-run"]) {
|
||||
const profile = opts.name || profileNameFromModel(modelId);
|
||||
const filePath = path.join(codexHome, `${profile}.config.toml`);
|
||||
printInfo(`[dry-run] would write ${filePath}`);
|
||||
return 0;
|
||||
}
|
||||
if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true });
|
||||
const profile = opts.name || profileNameFromModel(modelId);
|
||||
const filePath = path.join(codexHome, `${profile}.config.toml`);
|
||||
@@ -97,16 +181,22 @@ async function configureCodex(modelId, ctxWindow, opts) {
|
||||
}
|
||||
|
||||
export async function runConfigureCommand(cli, opts = {}, cmd) {
|
||||
const target = String(cli || "").toLowerCase();
|
||||
if (!SUPPORTED.includes(target)) {
|
||||
const target = resolveManifestTarget(cli, "configure");
|
||||
if (!target) {
|
||||
printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`);
|
||||
return 2;
|
||||
}
|
||||
if (opts.favorite && opts.unfavorite) {
|
||||
printError("Choose only one of --favorite or --unfavorite.");
|
||||
return 2;
|
||||
}
|
||||
const globalOpts = cmd ? cmd.optsWithGlobals() : {};
|
||||
const requestOpts = resolveConfigureTargetOptions({ ...globalOpts, ...opts });
|
||||
const contextKey = preferenceContextName({ ...globalOpts, ...opts });
|
||||
|
||||
let models;
|
||||
try {
|
||||
models = await fetchModels(globalOpts);
|
||||
models = await fetchModels(requestOpts);
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
return 1;
|
||||
@@ -122,12 +212,15 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
|
||||
chosenId = `${opts.provider}/${chosenId}`;
|
||||
}
|
||||
|
||||
if (!chosenId) {
|
||||
if (!chosenId && !opts.yes) {
|
||||
const ids = models.map((m) => (typeof m === "string" ? m : m.id));
|
||||
const preferences = loadModelPreferences();
|
||||
const rankedIds = rankPreferredModels(target, ids, preferences, contextKey);
|
||||
const preferenceState = getModelPreferenceState(target, preferences, contextKey);
|
||||
const providers = [...new Set(models.map(providerOf))].sort();
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
printHeading("Configure Codex CLI");
|
||||
printHeading(`Configure ${target} CLI`);
|
||||
let providerList = providers;
|
||||
if (opts.provider) {
|
||||
providerList = providers.filter((p) => p === opts.provider);
|
||||
@@ -136,8 +229,18 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
|
||||
const p = await prompt.ask("Provider");
|
||||
if (p) providerList = providers.filter((x) => x === p);
|
||||
}
|
||||
const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id))));
|
||||
const candidates = inProvider.length ? inProvider : ids;
|
||||
const inProvider = rankedIds.filter((id) =>
|
||||
providerList.includes(providerOf(byId(models, id)))
|
||||
);
|
||||
const candidates = inProvider.length ? inProvider : rankedIds;
|
||||
if (preferenceState.favorites.length) {
|
||||
printInfo(
|
||||
`Favorites: ${preferenceState.favorites.filter((id) => ids.includes(id)).join(", ")}`
|
||||
);
|
||||
}
|
||||
if (preferenceState.recent.length) {
|
||||
printInfo(`Recent: ${preferenceState.recent.filter((id) => ids.includes(id)).join(", ")}`);
|
||||
}
|
||||
printInfo(
|
||||
`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`
|
||||
);
|
||||
@@ -158,10 +261,48 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
|
||||
}
|
||||
const ctxWindow = contextWindowOf(entry);
|
||||
|
||||
let result;
|
||||
if (target === "codex") {
|
||||
return await configureCodex(chosenId, ctxWindow, opts);
|
||||
result = await configureCodex(chosenId, ctxWindow, opts);
|
||||
} else {
|
||||
const setup = SETUP_MODULES[target];
|
||||
if (!setup) {
|
||||
printError(`No setup recipe is registered for '${target}'.`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
try {
|
||||
const module = await import(setup.module);
|
||||
const runSetup = module[setup.exportName];
|
||||
if (typeof runSetup !== "function") {
|
||||
printError(`Setup recipe '${target}' is unavailable.`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const setupOpts = {
|
||||
...requestOpts,
|
||||
...opts,
|
||||
model: chosenId,
|
||||
// The picker already selected a model. Setup recipes that can generate
|
||||
// a model subset receive an exact filter; the others use `model`.
|
||||
...(target === "claude" || target === "continue" ? { only: chosenId } : {}),
|
||||
yes: true,
|
||||
};
|
||||
result = await runSetup(setupOpts);
|
||||
} catch (error) {
|
||||
printError(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
|
||||
if (result === 0 && !(opts.dryRun ?? opts["dry-run"])) {
|
||||
recordModelPreference(target, chosenId, {
|
||||
favorite: Boolean(opts.favorite),
|
||||
unfavorite: Boolean(opts.unfavorite),
|
||||
context: contextKey,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function byId(models, id) {
|
||||
@@ -177,12 +318,20 @@ export function registerConfigure(program) {
|
||||
.command("configure <cli>")
|
||||
.description(
|
||||
t("configure.description") ||
|
||||
"Pick a provider+model from the active server and write a local CLI config (v1: codex)"
|
||||
"Pick a provider+model from the active server and configure a supported local CLI"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL")
|
||||
.option("--context <name>", "Named local/remote context")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to the active context/env)")
|
||||
.option("--provider <id>", "Provider id (skips the interactive provider prompt)")
|
||||
.option("--model <id>", "Model id (skips the interactive model prompt)")
|
||||
.option("--name <name>", "Profile name to write (default: derived from model)")
|
||||
.option("--codex-home <dir>", "Codex home dir (default: ~/.codex)")
|
||||
.option("--yes", "Non-interactive; requires --model")
|
||||
.option("--favorite", "Remember the selected model as a favorite for this CLI")
|
||||
.option("--unfavorite", "Remove the selected model from this CLI's favorites")
|
||||
.option("--dry-run", "Preview the generated config without writing")
|
||||
.option(
|
||||
"--allow-container-write",
|
||||
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { loadContexts, saveContexts } from "../contexts.mjs";
|
||||
import { loadContexts, saveContextsSecure } from "../contexts.mjs";
|
||||
import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -31,7 +31,9 @@ export function normalizeBaseUrl(host, port) {
|
||||
|
||||
/** Derive a clean context name from a host (strip scheme/port). */
|
||||
export function hostLabel(host) {
|
||||
let value = String(host || "").trim().replace(/^https?:\/\//i, "");
|
||||
let value = String(host || "")
|
||||
.trim()
|
||||
.replace(/^https?:\/\//i, "");
|
||||
value = value.split("/")[0].split(":")[0];
|
||||
return value || "remote";
|
||||
}
|
||||
@@ -107,7 +109,7 @@ export async function runConnectCommand(host, opts = {}) {
|
||||
description: `Remote OmniRoute (${host})`,
|
||||
};
|
||||
cfg.currentContext = name;
|
||||
saveContexts(cfg);
|
||||
await saveContextsSecure(cfg);
|
||||
|
||||
printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`);
|
||||
printInfo("All commands now target this server.");
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import { t } from "../i18n.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs";
|
||||
import {
|
||||
loadContexts,
|
||||
saveContextsSecure,
|
||||
deleteContextCredential,
|
||||
migrateContextCredentials,
|
||||
resolveActiveContext,
|
||||
} from "../contexts.mjs";
|
||||
|
||||
/** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */
|
||||
function authLabel(c) {
|
||||
if (c?.accessToken) return "token";
|
||||
if (c?.apiKey) return "key";
|
||||
if (c?.credentialRef) return "keychain";
|
||||
return "✗";
|
||||
}
|
||||
|
||||
function contextMap(config) {
|
||||
return config.contexts || config.profiles || {};
|
||||
}
|
||||
|
||||
export async function confirm(msg) {
|
||||
// Non-interactive stdin (pipe, CI, EOF) cannot answer a [y/N] prompt. Asking
|
||||
// anyway leaves the readline question pending forever — Node then warns about an
|
||||
// "unsettled top-level await" at exit. Decline cleanly instead and point at the
|
||||
// non-interactive escape hatch so scripted callers fail safe rather than hang.
|
||||
if (!process.stdin.isTTY) {
|
||||
process.stderr.write(`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`);
|
||||
process.stderr.write(
|
||||
`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const readline = await import("node:readline");
|
||||
@@ -31,6 +44,18 @@ function maskKey(k) {
|
||||
return `${k.slice(0, 6)}***${k.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Return an export-safe copy without legacy or canonical context credentials. */
|
||||
export function redactContextSecrets(config) {
|
||||
const out = JSON.parse(JSON.stringify(config || {}));
|
||||
for (const collection of [out.contexts, out.profiles]) {
|
||||
for (const context of Object.values(collection || {})) {
|
||||
context.apiKey = null;
|
||||
delete context.accessToken;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function registerContexts(program) {
|
||||
const ctx = program
|
||||
.command("contexts")
|
||||
@@ -43,7 +68,7 @@ export function registerContexts(program) {
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const cfg = loadContexts();
|
||||
const rows = Object.entries(cfg.contexts || {}).map(([name, c]) => ({
|
||||
const rows = Object.entries(contextMap(cfg)).map(([name, c]) => ({
|
||||
active: name === (cfg.currentContext || "default") ? "●" : "",
|
||||
name,
|
||||
baseUrl: c.baseUrl || "",
|
||||
@@ -73,7 +98,7 @@ export function registerContexts(program) {
|
||||
.option("--description <d>", "Context description")
|
||||
.action(async (name, opts) => {
|
||||
const cfg = loadContexts();
|
||||
if (cfg.contexts?.[name]) {
|
||||
if (contextMap(cfg)[name]) {
|
||||
process.stderr.write(`Context '${name}' already exists. Remove or rename first.\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -86,29 +111,29 @@ export function registerContexts(program) {
|
||||
if (opts.accessTokenStdin) accessToken = value;
|
||||
else apiKey = value;
|
||||
}
|
||||
cfg.contexts = cfg.contexts || {};
|
||||
cfg.contexts[name] = {
|
||||
const contexts = contextMap(cfg);
|
||||
contexts[name] = {
|
||||
baseUrl: opts.url,
|
||||
accessToken: accessToken || undefined,
|
||||
apiKey,
|
||||
scope: opts.scope || undefined,
|
||||
description: opts.description || undefined,
|
||||
};
|
||||
saveContexts(cfg);
|
||||
await saveContextsSecure(cfg);
|
||||
process.stdout.write(`Added context '${name}'\n`);
|
||||
});
|
||||
|
||||
ctx
|
||||
.command("use <name>")
|
||||
.description("Switch active context")
|
||||
.action((name) => {
|
||||
.action(async (name) => {
|
||||
const cfg = loadContexts();
|
||||
if (!cfg.contexts?.[name]) {
|
||||
if (!contextMap(cfg)[name]) {
|
||||
process.stderr.write(`No such context: ${name}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
cfg.currentContext = name;
|
||||
saveContexts(cfg);
|
||||
await saveContextsSecure(cfg);
|
||||
process.stdout.write(`Active context: ${name}\n`);
|
||||
});
|
||||
|
||||
@@ -143,7 +168,7 @@ export function registerContexts(program) {
|
||||
.action((name, opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const cfg = loadContexts();
|
||||
const c = cfg.contexts?.[name];
|
||||
const c = contextMap(cfg)[name];
|
||||
if (!c) {
|
||||
process.stderr.write(`No such context: ${name}\n`);
|
||||
process.exit(2);
|
||||
@@ -151,6 +176,8 @@ export function registerContexts(program) {
|
||||
const display = {
|
||||
name,
|
||||
baseUrl: c.baseUrl,
|
||||
auth: authLabel(c),
|
||||
credentialRef: c.credentialRef || null,
|
||||
accessToken: maskKey(c.accessToken),
|
||||
apiKey: maskKey(c.apiKey),
|
||||
scope: c.scope,
|
||||
@@ -172,7 +199,7 @@ export function registerContexts(program) {
|
||||
}
|
||||
}
|
||||
const cfg = loadContexts();
|
||||
if (!cfg.contexts?.[name]) {
|
||||
if (!contextMap(cfg)[name]) {
|
||||
process.stderr.write(`No such context: ${name}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -180,29 +207,37 @@ export function registerContexts(program) {
|
||||
process.stderr.write("Cannot remove default context.\n");
|
||||
process.exit(2);
|
||||
}
|
||||
delete cfg.contexts[name];
|
||||
const contexts = contextMap(cfg);
|
||||
const deletedCredential = await deleteContextCredential(name, contexts[name]);
|
||||
if (contexts[name].credentialRef && !deletedCredential) {
|
||||
process.stderr.write(
|
||||
"Warning: could not remove the OS-keychain entry; the context reference was removed locally.\n"
|
||||
);
|
||||
}
|
||||
delete contexts[name];
|
||||
if (cfg.currentContext === name) cfg.currentContext = "default";
|
||||
saveContexts(cfg);
|
||||
await saveContextsSecure(cfg);
|
||||
process.stdout.write(`Removed context '${name}'\n`);
|
||||
});
|
||||
|
||||
ctx
|
||||
.command("rename <old> <new>")
|
||||
.description("Rename a context")
|
||||
.action((oldName, newName) => {
|
||||
.action(async (oldName, newName) => {
|
||||
const cfg = loadContexts();
|
||||
if (!cfg.contexts?.[oldName]) {
|
||||
const contexts = contextMap(cfg);
|
||||
if (!contexts[oldName]) {
|
||||
process.stderr.write(`No such context: ${oldName}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
if (cfg.contexts[newName]) {
|
||||
if (contexts[newName]) {
|
||||
process.stderr.write(`Context '${newName}' already exists.\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
cfg.contexts[newName] = cfg.contexts[oldName];
|
||||
delete cfg.contexts[oldName];
|
||||
contexts[newName] = contexts[oldName];
|
||||
delete contexts[oldName];
|
||||
if (cfg.currentContext === oldName) cfg.currentContext = newName;
|
||||
saveContexts(cfg);
|
||||
await saveContextsSecure(cfg);
|
||||
process.stdout.write(`Renamed '${oldName}' → '${newName}'\n`);
|
||||
});
|
||||
|
||||
@@ -213,13 +248,7 @@ export function registerContexts(program) {
|
||||
.option("--no-secrets", "Omit API keys from export")
|
||||
.action(async (opts, cmd) => {
|
||||
const cfg = loadContexts();
|
||||
const out = JSON.parse(JSON.stringify(cfg));
|
||||
if (opts.noSecrets) {
|
||||
for (const c of Object.values(out.contexts || {})) {
|
||||
c.apiKey = null;
|
||||
delete c.accessToken;
|
||||
}
|
||||
}
|
||||
const out = opts.noSecrets ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg));
|
||||
const json = JSON.stringify(out, null, 2);
|
||||
if (opts.out) {
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
@@ -248,7 +277,12 @@ export function registerContexts(program) {
|
||||
const cfg = opts.merge
|
||||
? loadContexts()
|
||||
: { version: 1, currentContext: "default", contexts: {} };
|
||||
const incoming = imported.contexts || {};
|
||||
if (!cfg.contexts && cfg.profiles) {
|
||||
cfg.contexts = cfg.profiles;
|
||||
delete cfg.profiles;
|
||||
}
|
||||
cfg.contexts = cfg.contexts || {};
|
||||
const incoming = imported.contexts || imported.profiles || {};
|
||||
let count = 0;
|
||||
for (const [name, raw] of Object.entries(incoming)) {
|
||||
if (typeof name !== "string" || !name) continue;
|
||||
@@ -265,7 +299,38 @@ export function registerContexts(program) {
|
||||
if (!opts.merge && typeof imported.currentContext === "string") {
|
||||
cfg.currentContext = imported.currentContext;
|
||||
}
|
||||
saveContexts(cfg);
|
||||
await saveContextsSecure(cfg);
|
||||
process.stdout.write(`Imported ${count} context(s)\n`);
|
||||
});
|
||||
|
||||
ctx
|
||||
.command("migrate")
|
||||
.description("Move legacy plaintext context credentials to the OS keychain")
|
||||
.option("--yes", "Confirm migration in non-interactive scripts")
|
||||
.action(async (opts) => {
|
||||
const cfg = loadContexts();
|
||||
const pending = Object.entries(cfg.contexts || cfg.profiles || {}).filter(
|
||||
([, context]) => context?.accessToken || context?.apiKey
|
||||
);
|
||||
if (!pending.length) {
|
||||
process.stdout.write("No plaintext context credentials found.\n");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!opts.yes &&
|
||||
!(await confirm(`Migrate ${pending.length} context credential(s) to keychain?`))
|
||||
) {
|
||||
process.stdout.write("Cancelled.\n");
|
||||
return;
|
||||
}
|
||||
const result = await migrateContextCredentials();
|
||||
if (!result.migrated) {
|
||||
process.stderr.write(
|
||||
"OS keychain unavailable; credentials remain in config.json mode 0600.\n"
|
||||
);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
process.stdout.write(`Migrated ${pending.length} context credential(s) to keychain.\n`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -229,18 +229,45 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
|
||||
stdio: "inherit",
|
||||
shell: shellValue,
|
||||
});
|
||||
let settled = false;
|
||||
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
|
||||
const signalHandlers = {};
|
||||
const cleanupSignalHandlers = () => {
|
||||
for (const signal of Object.keys(signalExitCode)) {
|
||||
process.removeListener(signal, signalHandlers[signal]);
|
||||
}
|
||||
};
|
||||
const finish = (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupSignalHandlers();
|
||||
resolve(code);
|
||||
};
|
||||
for (const signal of Object.keys(signalExitCode)) {
|
||||
signalHandlers[signal] = () => {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
// The child may have already exited between the signal and cleanup.
|
||||
}
|
||||
finish(signalExitCode[signal]);
|
||||
};
|
||||
process.once(signal, signalHandlers[signal]);
|
||||
}
|
||||
child.on("error", (err) => {
|
||||
if (err?.code === "ENOENT") {
|
||||
console.error(
|
||||
"The 'codex' CLI was not found in PATH. Install with:\n npm install -g @openai/codex"
|
||||
);
|
||||
resolve(127);
|
||||
finish(127);
|
||||
} else {
|
||||
console.error(String(err?.message || err));
|
||||
resolve(1);
|
||||
finish(1);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
child.on("exit", (code, signalName) => {
|
||||
finish(code ?? signalExitCode[signalName] ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -204,16 +204,43 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
|
||||
shell,
|
||||
...(process.platform === "win32" ? { windowsHide: true } : {}),
|
||||
});
|
||||
let settled = false;
|
||||
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
|
||||
const signalHandlers = {};
|
||||
const cleanupSignalHandlers = () => {
|
||||
for (const signal of Object.keys(signalExitCode)) {
|
||||
process.removeListener(signal, signalHandlers[signal]);
|
||||
}
|
||||
};
|
||||
const finish = (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupSignalHandlers();
|
||||
resolve(code);
|
||||
};
|
||||
for (const signal of Object.keys(signalExitCode)) {
|
||||
signalHandlers[signal] = () => {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
// The child may have already exited between the signal and cleanup.
|
||||
}
|
||||
finish(signalExitCode[signal]);
|
||||
};
|
||||
process.once(signal, signalHandlers[signal]);
|
||||
}
|
||||
child.on("error", (err) => {
|
||||
if (err && err.code === "ENOENT") {
|
||||
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
|
||||
resolve(127);
|
||||
finish(127);
|
||||
} else {
|
||||
console.error(String(err?.message || err));
|
||||
resolve(1);
|
||||
finish(1);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
child.on("exit", (code, signalName) => {
|
||||
finish(code ?? signalExitCode[signalName] ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -54,11 +54,20 @@ async function openBrowser(url) {
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus(endpoint, timeoutMs) {
|
||||
function targetApiOptions(opts = {}) {
|
||||
return {
|
||||
baseUrl: opts.baseUrl,
|
||||
context: opts.context,
|
||||
apiKey: opts.apiKey,
|
||||
timeout: opts.timeout,
|
||||
};
|
||||
}
|
||||
|
||||
async function pollStatus(endpoint, timeoutMs, opts = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(2000);
|
||||
const res = await apiFetch(endpoint);
|
||||
const res = await apiFetch(endpoint, targetApiOptions(opts));
|
||||
if (!res.ok) continue;
|
||||
const data = await res.json();
|
||||
if (data.status === "complete" || data.status === "completed") return data;
|
||||
@@ -85,7 +94,7 @@ async function runBrowserFlow(def, opts) {
|
||||
const authorizeUrl = `/api/oauth/${backendKey}/authorize${
|
||||
redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""
|
||||
}`;
|
||||
const startRes = await apiFetch(authorizeUrl, { method: "GET" });
|
||||
const startRes = await apiFetch(authorizeUrl, { ...targetApiOptions(opts), method: "GET" });
|
||||
if (!startRes.ok) {
|
||||
const detail = await safeErrorBody(startRes);
|
||||
process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}${detail}\n`);
|
||||
@@ -143,6 +152,7 @@ async function runBrowserFlow(def, opts) {
|
||||
}
|
||||
|
||||
const exchangeRes = await apiFetch(`/api/oauth/${backendKey}/exchange`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
body: {
|
||||
code,
|
||||
@@ -179,7 +189,7 @@ async function runImportFlow(def, opts) {
|
||||
const endpoint = opts.importFromSystem
|
||||
? `/api/oauth/${def.id}/auto-import`
|
||||
: `/api/oauth/${def.id}/import`;
|
||||
const res = await apiFetch(endpoint, { method: "POST" });
|
||||
const res = await apiFetch(endpoint, { ...targetApiOptions(opts), method: "POST" });
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Import failed: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
@@ -195,6 +205,7 @@ async function runSocialFlow(def, opts) {
|
||||
process.exit(2);
|
||||
}
|
||||
const startRes = await apiFetch(`/api/oauth/${def.id}/social-authorize`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
body: { social },
|
||||
});
|
||||
@@ -209,14 +220,18 @@ async function runSocialFlow(def, opts) {
|
||||
process.stderr.write("Waiting for social authorization...\n");
|
||||
const result = await pollStatus(
|
||||
`/api/oauth/${def.id}/social-exchange?state=${encodeURIComponent(start.state ?? "")}`,
|
||||
opts.timeout ?? 300000
|
||||
opts.timeout ?? 300000,
|
||||
opts
|
||||
);
|
||||
process.stdout.write(`Authorized: ${result.email ?? result.userId ?? "connected"}\n`);
|
||||
}
|
||||
|
||||
async function runDeviceFlow(def, opts) {
|
||||
const providerKey = resolveBackendKey(def.id);
|
||||
const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, { method: "POST" });
|
||||
const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
});
|
||||
if (!startRes.ok) {
|
||||
process.stderr.write(`Failed to start device flow: ${startRes.status}\n`);
|
||||
process.exit(1);
|
||||
@@ -233,12 +248,14 @@ async function runDeviceFlow(def, opts) {
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(intervalMs);
|
||||
const statusRes = await apiFetch(
|
||||
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`
|
||||
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`,
|
||||
targetApiOptions(opts)
|
||||
);
|
||||
if (!statusRes.ok) continue;
|
||||
const status = await statusRes.json();
|
||||
if (status.status === "complete" || status.status === "authorized") {
|
||||
await apiFetch(`/api/providers/${providerKey}/auth/apply`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
body: { state: start.state },
|
||||
});
|
||||
@@ -255,6 +272,7 @@ async function runDeviceFlow(def, opts) {
|
||||
}
|
||||
|
||||
export async function runOAuthStart(opts, cmd) {
|
||||
opts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
|
||||
const def = PROVIDERS_WITH_OAUTH.find((p) => p.id === opts.provider);
|
||||
if (!def) {
|
||||
process.stderr.write(
|
||||
@@ -275,10 +293,10 @@ export async function runOAuthStart(opts, cmd) {
|
||||
}
|
||||
|
||||
export async function runOAuthStatus(opts, cmd) {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const globalOpts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
|
||||
const params = new URLSearchParams();
|
||||
if (opts.provider) params.set("provider", opts.provider);
|
||||
const res = await apiFetch(`/api/providers?${params}`);
|
||||
const res = await apiFetch(`/api/providers?${params}`, targetApiOptions(globalOpts));
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
@@ -291,6 +309,7 @@ export async function runOAuthStatus(opts, cmd) {
|
||||
}
|
||||
|
||||
export async function runOAuthRevoke(opts, cmd) {
|
||||
opts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
|
||||
if (!opts.yes) {
|
||||
process.stdout.write(
|
||||
`Revoke OAuth for ${opts.provider}${opts.connectionId ? ` (${opts.connectionId})` : ""}? (yes/no) `
|
||||
@@ -303,8 +322,11 @@ export async function runOAuthRevoke(opts, cmd) {
|
||||
}
|
||||
const id = opts.connectionId;
|
||||
const res = id
|
||||
? await apiFetch(`/api/providers/${id}`, { method: "DELETE" })
|
||||
: await apiFetch(`/api/oauth/${opts.provider}/revoke`, { method: "POST" });
|
||||
? await apiFetch(`/api/providers/${id}`, { ...targetApiOptions(opts), method: "DELETE" })
|
||||
: await apiFetch(`/api/oauth/${opts.provider}/revoke`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -13,6 +13,9 @@ export function registerProvider(program) {
|
||||
omniroute providers test <name> — test a provider connection
|
||||
omniroute providers test-all — test all active connections
|
||||
omniroute providers validate — validate local configuration
|
||||
omniroute providers add <id> — add an API-key connection
|
||||
omniroute providers auth <id> — start an existing OAuth flow
|
||||
omniroute providers remove <id> — remove a connection (requires confirmation)
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
498
bin/cli/commands/provider-crud.mjs
Normal file
498
bin/cli/commands/provider-crud.mjs
Normal file
@@ -0,0 +1,498 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { apiFetch, statusToExitCode } from "../api.mjs";
|
||||
import { createPrompt, printError, printInfo, printSuccess } from "../io.mjs";
|
||||
import { runOAuthStart } from "./oauth.mjs";
|
||||
|
||||
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function isBlank(value) {
|
||||
return value === undefined || value === null || String(value).trim() === "";
|
||||
}
|
||||
|
||||
function credentialShape(value) {
|
||||
if (isBlank(value)) return { present: false, length: 0 };
|
||||
return { present: true, length: String(value).length };
|
||||
}
|
||||
|
||||
const SENSITIVE_FIELD_RE =
|
||||
/^(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret|client[_-]?secret|credential|authorization)$/i;
|
||||
|
||||
/**
|
||||
* Redact provider responses before they reach human or JSON output.
|
||||
*
|
||||
* The API normally masks credentials, but the CLI must remain safe when an
|
||||
* operator enables a server-side reveal/debug option or when a compatible
|
||||
* remote implementation returns a raw field. Presence and length are useful
|
||||
* for diagnostics; the value itself must never be printed.
|
||||
*/
|
||||
export function redactProviderResponse(value, key = "") {
|
||||
if (SENSITIVE_FIELD_RE.test(key)) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
return typeof value === "string" ? credentialShape(value) : "[redacted]";
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((entry) => redactProviderResponse(entry));
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([entryKey, entryValue]) => [
|
||||
entryKey,
|
||||
redactProviderResponse(entryValue, entryKey),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a provider connection from the response returned by /api/providers.
|
||||
* The server deliberately masks credentials, so this helper never needs to
|
||||
* inspect or log a secret.
|
||||
*/
|
||||
export function findConnectionFromResponse(body, selector) {
|
||||
const rows = Array.isArray(body?.connections)
|
||||
? body.connections
|
||||
: Array.isArray(body?.providers)
|
||||
? body.providers
|
||||
: Array.isArray(body)
|
||||
? body
|
||||
: [];
|
||||
const needle = String(selector || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!needle) return null;
|
||||
return (
|
||||
rows.find((row) => String(row?.id || "").toLowerCase() === needle) ||
|
||||
rows.find((row) =>
|
||||
String(row?.id || "")
|
||||
.toLowerCase()
|
||||
.startsWith(needle)
|
||||
) ||
|
||||
rows.find((row) => String(row?.name || "").toLowerCase() === needle) ||
|
||||
rows.find((row) => String(row?.provider || "").toLowerCase() === needle) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the API body without accepting management auth as a provider secret. */
|
||||
export function buildProviderPayload(provider, opts = {}, credential) {
|
||||
const body = {
|
||||
provider: String(provider || "").trim(),
|
||||
name: String(opts.name || provider || "").trim(),
|
||||
};
|
||||
if (!body.name) throw new Error("Provider name is required.");
|
||||
if (!isBlank(credential)) body.apiKey = String(credential);
|
||||
if (!isBlank(opts.defaultModel)) body.defaultModel = String(opts.defaultModel).trim();
|
||||
if (!isBlank(opts.priority)) {
|
||||
const priority = Number(opts.priority);
|
||||
if (!Number.isInteger(priority) || priority < 1) {
|
||||
throw new Error("--priority must be a positive integer.");
|
||||
}
|
||||
body.priority = priority;
|
||||
}
|
||||
if (opts.providerSpecificData) {
|
||||
const raw = typeof opts.providerSpecificData === "string" ? opts.providerSpecificData : null;
|
||||
try {
|
||||
const parsed = raw ? JSON.parse(raw) : opts.providerSpecificData;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("must be a JSON object");
|
||||
}
|
||||
body.providerSpecificData = parsed;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`--provider-specific-data must be a JSON object (${error instanceof Error ? error.message : String(error)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Resolve a credential from an explicit value, env reference, stdin, or prompt. */
|
||||
export async function resolveProviderCredential(opts = {}, { prompt = true } = {}) {
|
||||
// Commander represents the negated `--no-credential` option as
|
||||
// `credential === false`. It is a control flag, never the literal provider
|
||||
// credential "false".
|
||||
if (opts.credential === false || opts.noCredential === true) return undefined;
|
||||
if (!isBlank(opts.credential)) return String(opts.credential).trim();
|
||||
|
||||
const envName = String(opts.credentialEnv || opts["credential-env"] || "").trim();
|
||||
if (envName) {
|
||||
if (!ENV_NAME_RE.test(envName)) throw new Error("--credential-env must be a valid env name.");
|
||||
const value = process.env[envName];
|
||||
if (isBlank(value)) throw new Error(`Environment variable ${envName} is empty or unset.`);
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
if (opts.credentialStdin || opts["credential-stdin"]) {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const value = chunks.join("").trim();
|
||||
if (!value) throw new Error("Credential stdin was empty.");
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!prompt) return undefined;
|
||||
const input = createPrompt();
|
||||
try {
|
||||
const value = await input.askSecret("Provider credential (hidden)");
|
||||
const trimmed = String(value || "").trim();
|
||||
if (!trimmed) throw new Error("Provider credential is required.");
|
||||
return trimmed;
|
||||
} finally {
|
||||
input.close();
|
||||
}
|
||||
}
|
||||
|
||||
function targetOptions(opts = {}) {
|
||||
return {
|
||||
// Passing the global values through lets api.mjs apply its context-first
|
||||
// auth precedence. A caller-supplied --base-url remains an explicit target.
|
||||
baseUrl: opts.baseUrl,
|
||||
context: opts.context,
|
||||
apiKey: opts.apiKey,
|
||||
timeout: opts.timeout,
|
||||
};
|
||||
}
|
||||
|
||||
async function readApiError(response) {
|
||||
try {
|
||||
const body = await response.json();
|
||||
const message = body?.error?.message || body?.error || body?.message;
|
||||
return message ? String(message) : `HTTP ${response.status}`;
|
||||
} catch {
|
||||
return `HTTP ${response.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function listRemoteConnections(opts) {
|
||||
return apiFetch("/api/providers?limit=5000", {
|
||||
...targetOptions(opts),
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveRemoteConnection(selector, opts) {
|
||||
const response = await listRemoteConnections(opts);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
const connection = findConnectionFromResponse(await response.json(), selector);
|
||||
if (!connection) throw new Error(`Provider connection not found: ${selector}`);
|
||||
return connection;
|
||||
}
|
||||
|
||||
export async function runProviderAddCommand(provider, opts = {}) {
|
||||
const normalized = String(provider || "").trim();
|
||||
if (!normalized) {
|
||||
printError("Provider id is required.");
|
||||
return 2;
|
||||
}
|
||||
if (opts.oauth) {
|
||||
if (opts.dryRun) {
|
||||
if (!opts.silent) {
|
||||
const preview = { action: "providers.auth", provider: normalized };
|
||||
if (opts.json) console.log(JSON.stringify(preview, null, 2));
|
||||
else printInfo(`dry-run: would start OAuth for ${normalized}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return runOAuthStart({ ...opts, provider: normalized }, opts.command);
|
||||
}
|
||||
|
||||
const allowNoCredential = Boolean(
|
||||
opts.allowNoCredential || opts.noCredential || opts.credential === false
|
||||
);
|
||||
let credential;
|
||||
try {
|
||||
credential = await resolveProviderCredential(opts, {
|
||||
prompt: !opts.dryRun && !opts.yes && !allowNoCredential,
|
||||
});
|
||||
if (!credential && !opts.dryRun && !allowNoCredential) {
|
||||
throw new Error(
|
||||
"Provider credential is required (use --credential-stdin or --credential-env)."
|
||||
);
|
||||
}
|
||||
const payload = buildProviderPayload(normalized, opts, credential);
|
||||
if (opts.dryRun) {
|
||||
const preview = {
|
||||
action: "providers.add",
|
||||
provider: payload.provider,
|
||||
name: payload.name,
|
||||
defaultModel: payload.defaultModel || null,
|
||||
credential: credentialShape(credential),
|
||||
providerSpecificData: payload.providerSpecificData
|
||||
? redactProviderResponse(payload.providerSpecificData)
|
||||
: null,
|
||||
};
|
||||
if (!opts.silent) {
|
||||
if (opts.json) console.log(JSON.stringify(preview, null, 2));
|
||||
else printInfo(`dry-run: would add ${payload.provider}/${payload.name}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const response = await apiFetch("/api/providers", {
|
||||
...targetOptions(opts),
|
||||
method: "POST",
|
||||
body: payload,
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!response.ok) {
|
||||
printError(await readApiError(response));
|
||||
return statusToExitCode(response.status);
|
||||
}
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!opts.silent) {
|
||||
if (opts.json) console.log(JSON.stringify(redactProviderResponse(body), null, 2));
|
||||
else printSuccess(`Added provider connection '${body?.connection?.name || payload.name}'.`);
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
printError(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProviderImportCommand(file, opts = {}) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch (error) {
|
||||
printError(
|
||||
`Cannot read provider import file: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const entries = Array.isArray(parsed)
|
||||
? parsed
|
||||
: Array.isArray(parsed?.providers)
|
||||
? parsed.providers
|
||||
: [parsed];
|
||||
if (!entries.length) {
|
||||
printError("Provider import file contains no entries.");
|
||||
return 2;
|
||||
}
|
||||
const results = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== "object" || !entry.provider) {
|
||||
results.push({ ok: false, error: "entry.provider is required" });
|
||||
if (!opts.continueOnError) break;
|
||||
continue;
|
||||
}
|
||||
const code = await runProviderAddCommand(entry.provider, {
|
||||
...opts,
|
||||
...entry,
|
||||
credential: entry.apiKey ?? entry.credential,
|
||||
dryRun: opts.dryRun,
|
||||
yes: true,
|
||||
silent: true,
|
||||
allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential,
|
||||
});
|
||||
results.push({ provider: entry.provider, ok: code === 0, code });
|
||||
if (code !== 0 && !opts.continueOnError) break;
|
||||
}
|
||||
if (opts.json) console.log(JSON.stringify({ file, results }, null, 2));
|
||||
return results.every((result) => result.ok) ? 0 : 1;
|
||||
}
|
||||
|
||||
async function confirmRemoval(label, opts) {
|
||||
if (opts.yes) return true;
|
||||
if (!process.stdin.isTTY) {
|
||||
printError(`Removal of '${label}' declined on non-interactive stdin; pass --yes to confirm.`);
|
||||
return false;
|
||||
}
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
const answer = await prompt.ask(`Remove provider connection '${label}'? [y/N] `);
|
||||
return /^y(?:es)?$/i.test(String(answer || "").trim());
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProviderRemoveCommand(selector, opts = {}) {
|
||||
if (!selector) {
|
||||
printError("Provider connection id, name, or provider is required.");
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
if (opts.dryRun) {
|
||||
const connection = await resolveRemoteConnection(selector, opts);
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
redactProviderResponse({ action: "providers.remove", connection }),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} else printInfo(`dry-run: would remove ${connection.name || connection.id}`);
|
||||
return 0;
|
||||
}
|
||||
const connection = await resolveRemoteConnection(selector, opts);
|
||||
if (!(await confirmRemoval(connection.name || connection.id, opts))) return 0;
|
||||
const response = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
|
||||
...targetOptions(opts),
|
||||
method: "DELETE",
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!response.ok) {
|
||||
printError(await readApiError(response));
|
||||
return statusToExitCode(response.status);
|
||||
}
|
||||
if (opts.json)
|
||||
console.log(JSON.stringify(redactProviderResponse({ removed: connection }), null, 2));
|
||||
else printSuccess(`Removed provider connection '${connection.name || connection.id}'.`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
printError(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProviderEditCommand(selector, opts = {}) {
|
||||
try {
|
||||
const connection = await resolveRemoteConnection(selector, opts);
|
||||
const body = {};
|
||||
if (opts.name !== undefined) body.name = opts.name;
|
||||
if (opts.defaultModel !== undefined) body.defaultModel = opts.defaultModel || null;
|
||||
if (opts.priority !== undefined) body.priority = Number(opts.priority);
|
||||
if (opts.active !== undefined) body.isActive = Boolean(opts.active);
|
||||
if (opts.inactive !== undefined) body.isActive = false;
|
||||
const credential = await resolveProviderCredential(opts, { prompt: false });
|
||||
if (credential) body.apiKey = credential;
|
||||
if (Object.keys(body).length === 0) {
|
||||
printError(
|
||||
"At least one edit field is required (--name, --default-model, --priority, --active/--inactive, or credential)."
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
if (opts.dryRun) {
|
||||
const preview = {
|
||||
action: "providers.edit",
|
||||
connection: redactProviderResponse(connection),
|
||||
changes: { ...body, apiKey: credentialShape(body.apiKey) },
|
||||
};
|
||||
if (opts.json) console.log(JSON.stringify(preview, null, 2));
|
||||
else printInfo(`dry-run: would edit ${connection.name || connection.id}`);
|
||||
return 0;
|
||||
}
|
||||
const response = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
|
||||
...targetOptions(opts),
|
||||
method: "PUT",
|
||||
body,
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!response.ok) {
|
||||
printError(await readApiError(response));
|
||||
return statusToExitCode(response.status);
|
||||
}
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (opts.json) console.log(JSON.stringify(redactProviderResponse(result), null, 2));
|
||||
else printSuccess(`Updated provider connection '${connection.name || connection.id}'.`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
printError(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProviderAuthCommand(provider, opts = {}, cmd) {
|
||||
return runOAuthStart({ ...opts, provider }, cmd);
|
||||
}
|
||||
|
||||
export function registerProviderCrud(providers) {
|
||||
providers
|
||||
.command("add <provider>")
|
||||
.description("Add an API-key provider connection through the active local/remote server")
|
||||
.option("--name <name>", "Connection name (defaults to provider id)")
|
||||
.option(
|
||||
"--credential <key>",
|
||||
"Provider credential (prefer --credential-stdin or --credential-env)"
|
||||
)
|
||||
.option("--credential-env <name>", "Read provider credential from an environment variable")
|
||||
.option("--credential-stdin", "Read provider credential from stdin")
|
||||
.option("--allow-no-credential", "Allow providers whose catalog marks the credential optional")
|
||||
.option("--no-credential", "Allow providers whose catalog marks the credential optional")
|
||||
.option("--default-model <id>", "Default model for this connection")
|
||||
.option("--priority <n>", "Connection priority", Number)
|
||||
.option("--provider-specific-data <json>", "Provider-specific settings as a JSON object")
|
||||
.option("--oauth", "Start the provider's existing OAuth flow instead")
|
||||
.option("--yes", "Do not prompt for a credential")
|
||||
.option("--dry-run", "Preview the request without writing")
|
||||
.option("--json", "Print machine-readable output")
|
||||
.action(async (provider, opts, cmd) => {
|
||||
const code = await runProviderAddCommand(provider, {
|
||||
...cmd.parent.optsWithGlobals(),
|
||||
...opts,
|
||||
command: cmd,
|
||||
});
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
|
||||
providers
|
||||
.command("import <file>")
|
||||
.description("Import provider connections from a JSON file")
|
||||
.option("--continue-on-error", "Continue importing after a failed entry")
|
||||
.option("--dry-run", "Preview requests without writing")
|
||||
.option("--json", "Print machine-readable output")
|
||||
.action(async (file, opts, cmd) => {
|
||||
const code = await runProviderImportCommand(file, {
|
||||
...cmd.parent.optsWithGlobals(),
|
||||
...opts,
|
||||
});
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
|
||||
providers
|
||||
.command("auth <provider>")
|
||||
.description("Start an existing OAuth flow for a provider")
|
||||
.option("--no-browser", "Print the authorization URL instead of opening a browser")
|
||||
.option("--import-from-system", "Import credentials from the local system when supported")
|
||||
.option("--social <provider>", "Use a social-login flow when supported")
|
||||
.option("--timeout <ms>", "OAuth timeout", Number, 300000)
|
||||
.action(async (provider, opts, cmd) => {
|
||||
const code = await runProviderAuthCommand(
|
||||
provider,
|
||||
{ ...cmd.parent.optsWithGlobals(), ...opts },
|
||||
cmd
|
||||
);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
|
||||
providers
|
||||
.command("remove <idOrName>")
|
||||
.description("Remove one provider connection from the active local/remote server")
|
||||
.option("--yes", "Confirm removal")
|
||||
.option("--dry-run", "Preview the removal without writing")
|
||||
.option("--json", "Print machine-readable output")
|
||||
.action(async (idOrName, opts, cmd) => {
|
||||
const code = await runProviderRemoveCommand(idOrName, {
|
||||
...cmd.parent.optsWithGlobals(),
|
||||
...opts,
|
||||
});
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
|
||||
providers
|
||||
.command("edit <idOrName>")
|
||||
.description("Edit one provider connection on the active local/remote server")
|
||||
.option("--name <name>", "New connection name")
|
||||
.option("--default-model <id>", "New default model")
|
||||
.option("--priority <n>", "New connection priority", Number)
|
||||
.option("--active", "Activate the connection")
|
||||
.option("--inactive", "Deactivate the connection")
|
||||
.option("--credential <key>", "Replace provider credential")
|
||||
.option("--credential-env <name>", "Read replacement credential from an environment variable")
|
||||
.option("--credential-stdin", "Read replacement credential from stdin")
|
||||
.option("--dry-run", "Preview the edit without writing")
|
||||
.option("--json", "Print machine-readable output")
|
||||
.action(async (idOrName, opts, cmd) => {
|
||||
const code = await runProviderEditCommand(idOrName, {
|
||||
...cmd.parent.optsWithGlobals(),
|
||||
...opts,
|
||||
});
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { encryptCredential } from "../encryption.mjs";
|
||||
import { openOmniRouteDb } from "../sqlite.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { registerProviderCrud } from "./provider-crud.mjs";
|
||||
|
||||
function publicConnection(connection) {
|
||||
return {
|
||||
@@ -604,6 +605,8 @@ export function registerProviders(program) {
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
registerProviderCrud(providers);
|
||||
|
||||
extendProvidersMetrics(providers);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,28 +16,16 @@ import {
|
||||
import { t } from "../i18n.mjs";
|
||||
import os from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
const RUN_TARGETS = {
|
||||
claude: {
|
||||
aliases: ["claude", "claude-code", "cc"],
|
||||
description: "Claude Code",
|
||||
},
|
||||
codex: {
|
||||
aliases: ["codex", "openai-codex", "openai"],
|
||||
description: "OpenAI Codex CLI",
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {Record<string,string>} */
|
||||
const RUN_TARGET_ALIAS_TO_CANONICAL = {
|
||||
claude: "claude",
|
||||
"claude-code": "claude",
|
||||
cc: "claude",
|
||||
codex: "codex",
|
||||
"openai-codex": "codex",
|
||||
openai: "codex",
|
||||
};
|
||||
import { quoteShellArgs } from "../utils/winShellArgs.mjs";
|
||||
import {
|
||||
listManifestTargets,
|
||||
manifestModelArgs,
|
||||
manifestRequiresModel,
|
||||
resolveManifestTarget,
|
||||
} from "../cli-manifest.mjs";
|
||||
|
||||
function isBlank(value) {
|
||||
return value === undefined || value === null || String(value).trim() === "";
|
||||
@@ -48,6 +36,11 @@ function toAuthSource(targetOpts) {
|
||||
!isBlank(targetOpts.token) || !isBlank(targetOpts.apiKey) || !isBlank(targetOpts["api-key"]);
|
||||
if (explicit) return "option";
|
||||
|
||||
const envName = String(targetOpts.apiKeyEnv || targetOpts["api-key-env"] || "").trim();
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName) && !isBlank(process.env[envName])) {
|
||||
return "env";
|
||||
}
|
||||
|
||||
try {
|
||||
const context = resolveActiveContext(targetOpts.context || process.env.OMNIROUTE_CONTEXT);
|
||||
if (context && (context.accessToken || context.apiKey)) return "context";
|
||||
@@ -60,16 +53,23 @@ function toAuthSource(targetOpts) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
/** Resolve supported target to canonical id. */
|
||||
/** Resolve a token option without ever printing its value in a plan. */
|
||||
function resolveAuthTokenOption(targetOpts = {}) {
|
||||
const direct = targetOpts.token || targetOpts.apiKey || targetOpts["api-key"];
|
||||
if (!isBlank(direct)) return direct;
|
||||
|
||||
const envName = String(targetOpts.apiKeyEnv || targetOpts["api-key-env"] || "").trim();
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName)) return process.env[envName];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Resolve supported target (id or alias) to canonical id via the manifest. */
|
||||
export function resolveRunTarget(target) {
|
||||
const raw = String(target || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return RUN_TARGET_ALIAS_TO_CANONICAL[raw];
|
||||
return resolveManifestTarget(target, "run");
|
||||
}
|
||||
|
||||
export function listRunTargets() {
|
||||
return Object.keys(RUN_TARGETS);
|
||||
return listManifestTargets("run");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,8 +116,8 @@ async function buildClaudePlan(rawOpts, args = []) {
|
||||
const merged = {
|
||||
...rawOpts,
|
||||
model,
|
||||
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
|
||||
token: rawOpts.token || rawOpts.apiKey || rawOpts["api-key"],
|
||||
apiKey: resolveAuthTokenOption(rawOpts),
|
||||
token: resolveAuthTokenOption(rawOpts),
|
||||
profile: rawOpts.profile ?? rawOpts.p,
|
||||
};
|
||||
|
||||
@@ -142,7 +142,7 @@ async function buildClaudePlan(rawOpts, args = []) {
|
||||
args: quotedArgs,
|
||||
model: merged.model || undefined,
|
||||
envDiff: envPreview(process.env, env),
|
||||
authSource: toAuthSource(merged),
|
||||
authSource: toAuthSource(rawOpts),
|
||||
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
|
||||
};
|
||||
}
|
||||
@@ -151,7 +151,7 @@ async function buildCodexPlan(rawOpts, args = []) {
|
||||
const model = resolveModelFromTargetOptions(rawOpts);
|
||||
const merged = {
|
||||
...rawOpts,
|
||||
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
|
||||
apiKey: resolveAuthTokenOption(rawOpts),
|
||||
model,
|
||||
profile: rawOpts.profile ?? rawOpts.p,
|
||||
};
|
||||
@@ -174,25 +174,303 @@ async function buildCodexPlan(rawOpts, args = []) {
|
||||
args: quotedArgs,
|
||||
model: merged.model || undefined,
|
||||
envDiff: envPreview(process.env, env),
|
||||
authSource: toAuthSource(merged),
|
||||
authSource: toAuthSource(rawOpts),
|
||||
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
|
||||
providerArgs,
|
||||
profileArgs,
|
||||
};
|
||||
}
|
||||
|
||||
const NO_AUTH_SENTINEL = "omniroute-no-auth";
|
||||
|
||||
function resolveGenericSpawn(command) {
|
||||
if (process.platform !== "win32") return { command, shell: undefined };
|
||||
|
||||
try {
|
||||
const output = execFileSync("where.exe", [command], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const matches = output
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
const preferred = matches.find((value) => /\.exe$/i.test(value));
|
||||
if (preferred) return { command: preferred, shell: undefined };
|
||||
const shim = matches.find((value) => /\.(?:cmd|bat)$/i.test(value));
|
||||
if (shim) return { command: shim, shell: true };
|
||||
} catch {
|
||||
// Fall through to the conventional npm shim.
|
||||
}
|
||||
|
||||
return { command: `${command}.cmd`, shell: true };
|
||||
}
|
||||
|
||||
function genericEnv(baseEnv, kind, baseUrl, authToken, model) {
|
||||
const env = { ...baseEnv };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (kind === "aider" && /^(OPENAI_API_KEY|OPENAI_API_BASE|OPENAI_BASE_URL)$/.test(key)) {
|
||||
delete env[key];
|
||||
}
|
||||
if (
|
||||
kind === "goose" &&
|
||||
(/^(OPENAI_API_KEY|OPENAI_API_BASE|OPENAI_BASE_URL)$/.test(key) || key.startsWith("GOOSE_"))
|
||||
) {
|
||||
delete env[key];
|
||||
}
|
||||
if (kind === "opencode" && key === "OPENCODE_CONFIG_CONTENT") delete env[key];
|
||||
if (kind === "qwen" && (key === "QWEN_HOME" || key === "OMNIROUTE_API_KEY")) {
|
||||
delete env[key];
|
||||
}
|
||||
if (
|
||||
kind === "gemini" &&
|
||||
/^(GOOGLE_GEMINI_BASE_URL|GEMINI_API_KEY|GOOGLE_API_KEY|GEMINI_CLI_HOME|GEMINI_DEFAULT_AUTH_TYPE|GOOGLE_GENAI_USE_VERTEXAI|GOOGLE_GENAI_USE_GCA)$/.test(
|
||||
key
|
||||
)
|
||||
) {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
|
||||
const token = (authToken && String(authToken).trim()) || NO_AUTH_SENTINEL;
|
||||
if (kind === "aider") {
|
||||
env.OPENAI_API_BASE = baseUrl;
|
||||
env.OPENAI_API_KEY = token;
|
||||
} else if (kind === "goose") {
|
||||
env.GOOSE_PROVIDER = "openai";
|
||||
env.OPENAI_HOST = baseUrl;
|
||||
env.OPENAI_API_KEY = token;
|
||||
if (model) env.GOOSE_MODEL = model;
|
||||
} else if (kind === "opencode") {
|
||||
env.OMNIROUTE_API_KEY = token;
|
||||
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
omniroute: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "OmniRoute",
|
||||
options: {
|
||||
baseURL: ensureV1BaseUrl(baseUrl),
|
||||
apiKey: "{env:OMNIROUTE_API_KEY}",
|
||||
},
|
||||
...(model ? { models: { [model]: { name: model } } } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (kind === "qwen") {
|
||||
env.OMNIROUTE_API_KEY = token;
|
||||
} else if (kind === "gemini") {
|
||||
// Verified against @google/gemini-cli 0.50.0: the SDK appends
|
||||
// /v1beta/models/<model>:generateContent to this base URL, which is
|
||||
// OmniRoute's native Gemini surface. Auth is the API-key path; the
|
||||
// isolated GEMINI_CLI_HOME (set at spawn time) keeps any stored OAuth
|
||||
// session from overriding it.
|
||||
env.GOOGLE_GEMINI_BASE_URL = baseUrl;
|
||||
env.GEMINI_API_KEY = token;
|
||||
env.GEMINI_DEFAULT_AUTH_TYPE = "gemini-api-key";
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function ensureV1BaseUrl(baseUrl) {
|
||||
const normalized = String(baseUrl || "").replace(/\/+$/, "");
|
||||
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
|
||||
}
|
||||
|
||||
function modelArgsForTarget(target, model) {
|
||||
return manifestModelArgs(target, model);
|
||||
}
|
||||
|
||||
function buildGeminiSettings() {
|
||||
// Force API-key auth in the isolated home so the operator's stored OAuth
|
||||
// session (Code Assist) never leaks into an OmniRoute-directed launch.
|
||||
return JSON.stringify({ security: { auth: { selectedType: "gemini-api-key" } } }, null, 2);
|
||||
}
|
||||
|
||||
function buildQwenSettings(baseUrl, model) {
|
||||
const qwenBaseUrl = ensureV1BaseUrl(baseUrl);
|
||||
return JSON.stringify(
|
||||
{
|
||||
modelProviders: {
|
||||
openai: [
|
||||
{
|
||||
id: model,
|
||||
name: `${model} (OmniRoute)`,
|
||||
envKey: "OMNIROUTE_API_KEY",
|
||||
baseUrl: qwenBaseUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
security: { auth: { selectedType: "openai" } },
|
||||
model: { name: model, baseUrl: qwenBaseUrl },
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
async function buildGenericPlan(target, rawOpts, args = []) {
|
||||
const { baseUrl, authToken } = resolveLaunchTarget({
|
||||
...rawOpts,
|
||||
apiKey: resolveAuthTokenOption(rawOpts),
|
||||
});
|
||||
const commandSpec = resolveGenericSpawn(target);
|
||||
const model = resolveModelFromTargetOptions(rawOpts);
|
||||
if (manifestRequiresModel(target) && !model) {
|
||||
throw new Error("Qwen Code requires --model in non-interactive OmniRoute launches");
|
||||
}
|
||||
const modelArgs = modelArgsForTarget(target, model);
|
||||
const fullArgs = [...modelArgs, ...args];
|
||||
const env = genericEnv(process.env, target, baseUrl, authToken, model);
|
||||
|
||||
return {
|
||||
target,
|
||||
baseUrl,
|
||||
command: commandSpec.command,
|
||||
shell: commandSpec.shell,
|
||||
args: quoteShellArgs(fullArgs, process.platform),
|
||||
model: model || undefined,
|
||||
envDiff: envPreview(process.env, env),
|
||||
authSource: toAuthSource(rawOpts),
|
||||
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
|
||||
modelArgs,
|
||||
configOverlay:
|
||||
target === "qwen"
|
||||
? "temporary QWEN_HOME (removed after exit)"
|
||||
: target === "gemini"
|
||||
? "temporary GEMINI_CLI_HOME (removed after exit)"
|
||||
: target === "opencode"
|
||||
? "OPENCODE_CONFIG_CONTENT (process environment only)"
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function healthCheckForRun(baseUrl) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runGenericTarget(target, rawOpts, args) {
|
||||
const { baseUrl, authToken } = resolveLaunchTarget({
|
||||
...rawOpts,
|
||||
apiKey: resolveAuthTokenOption(rawOpts),
|
||||
});
|
||||
if (!(await healthCheckForRun(baseUrl))) {
|
||||
console.error(`OmniRoute is not reachable at ${baseUrl}. Start it or check --remote.`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const model = resolveModelFromTargetOptions(rawOpts);
|
||||
if (manifestRequiresModel(target) && !model) {
|
||||
console.error("Qwen Code requires --model in non-interactive OmniRoute launches.");
|
||||
return 2;
|
||||
}
|
||||
const modelArgs = modelArgsForTarget(target, model);
|
||||
const commandSpec = resolveGenericSpawn(target);
|
||||
const childEnv = genericEnv(process.env, target, baseUrl, authToken, model);
|
||||
let overlayHome;
|
||||
if (target === "qwen") {
|
||||
overlayHome = mkdtempSync(join(os.tmpdir(), "omniroute-qwen-run-"));
|
||||
writeFileSync(join(overlayHome, "settings.json"), buildQwenSettings(baseUrl, model), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
childEnv.QWEN_HOME = overlayHome;
|
||||
} else if (target === "gemini") {
|
||||
overlayHome = mkdtempSync(join(os.tmpdir(), "omniroute-gemini-run-"));
|
||||
mkdirSync(join(overlayHome, ".gemini"), { recursive: true });
|
||||
writeFileSync(join(overlayHome, ".gemini", "settings.json"), buildGeminiSettings(), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
childEnv.GEMINI_CLI_HOME = overlayHome;
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
commandSpec.command,
|
||||
quoteShellArgs([...modelArgs, ...args], process.platform),
|
||||
{
|
||||
env: childEnv,
|
||||
stdio: "inherit",
|
||||
shell: commandSpec.shell,
|
||||
...(process.platform === "win32" ? { windowsHide: true } : {}),
|
||||
}
|
||||
);
|
||||
|
||||
const cleanup = () => {
|
||||
if (!overlayHome) return;
|
||||
try {
|
||||
rmSync(overlayHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup; the directory contains no persistent credentials.
|
||||
}
|
||||
};
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
|
||||
const finish = (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
for (const signal of Object.keys(signalExitCode)) {
|
||||
process.removeListener(signal, signalHandlers[signal]);
|
||||
}
|
||||
cleanup();
|
||||
resolve(code);
|
||||
};
|
||||
const signalHandlers = {};
|
||||
for (const signal of Object.keys(signalExitCode)) {
|
||||
signalHandlers[signal] = () => {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
// The child may have already exited between the signal and cleanup.
|
||||
}
|
||||
finish(signalExitCode[signal]);
|
||||
};
|
||||
process.once(signal, signalHandlers[signal]);
|
||||
}
|
||||
child.on("error", (error) => {
|
||||
if (error?.code === "ENOENT") {
|
||||
console.error(`The '${target}' CLI was not found in PATH.`);
|
||||
finish(127);
|
||||
} else {
|
||||
console.error(String(error?.message || error));
|
||||
finish(1);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
finish(code ?? signalExitCode[signal] ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a launch plan and redact any resolved secret values. */
|
||||
export async function buildRunPlan(target, rawOpts = {}, args = []) {
|
||||
const canonical = resolveRunTarget(target);
|
||||
if (!canonical) {
|
||||
throw new Error("unsupported target");
|
||||
throw new Error(
|
||||
`Unsupported target '${target}'. Supported targets: ${listRunTargets().join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (canonical === "claude") {
|
||||
return buildClaudePlan(rawOpts, args);
|
||||
}
|
||||
|
||||
return buildCodexPlan(rawOpts, args);
|
||||
if (canonical === "codex") {
|
||||
return buildCodexPlan(rawOpts, args);
|
||||
}
|
||||
|
||||
return buildGenericPlan(canonical, rawOpts, args);
|
||||
}
|
||||
|
||||
function writeDryRunOutput(plan, opts = {}) {
|
||||
@@ -207,6 +485,7 @@ function writeDryRunOutput(plan, opts = {}) {
|
||||
},
|
||||
shell: !!plan.shell,
|
||||
model: plan.model || null,
|
||||
configOverlay: plan.configOverlay || null,
|
||||
env: {
|
||||
changedOrAdded: plan.envDiff.changedOrAdded,
|
||||
removed: plan.envDiff.removed,
|
||||
@@ -224,6 +503,7 @@ function writeDryRunOutput(plan, opts = {}) {
|
||||
console.log(`args: ${JSON.stringify(output.args)}`);
|
||||
console.log(`auth: ${JSON.stringify(output.auth)}`);
|
||||
console.log(`model: ${output.model || "(not set)"}`);
|
||||
if (output.configOverlay) console.log(`config overlay: ${output.configOverlay}`);
|
||||
if (output.env.changedOrAdded.length) {
|
||||
console.log(`env added/changed: ${output.env.changedOrAdded.join(", ")}`);
|
||||
}
|
||||
@@ -237,8 +517,8 @@ function buildExecutionOptionsForClaude(rawOpts) {
|
||||
return {
|
||||
...rawOpts,
|
||||
model: resolveModelFromTargetOptions(rawOpts),
|
||||
token: rawOpts.token || rawOpts.apiKey || rawOpts["api-key"],
|
||||
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
|
||||
token: resolveAuthTokenOption(rawOpts),
|
||||
apiKey: resolveAuthTokenOption(rawOpts),
|
||||
profile: rawOpts.profile || rawOpts.p,
|
||||
};
|
||||
}
|
||||
@@ -247,7 +527,7 @@ function buildExecutionOptionsForCodex(rawOpts) {
|
||||
return {
|
||||
...rawOpts,
|
||||
model: resolveModelFromTargetOptions(rawOpts),
|
||||
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
|
||||
apiKey: resolveAuthTokenOption(rawOpts),
|
||||
profile: rawOpts.profile || rawOpts.p,
|
||||
};
|
||||
}
|
||||
@@ -262,12 +542,18 @@ export async function runCliTarget(target, opts = {}, args = []) {
|
||||
const canonical = resolveRunTarget(target);
|
||||
if (!canonical) {
|
||||
process.stderr.write(
|
||||
`Unsupported target '${target}'. Supported targets: ${Object.keys(RUN_TARGETS).join(", ")}\n`
|
||||
`Unsupported target '${target}'. Supported targets: ${listRunTargets().join(", ")}\n`
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const plan = await buildRunPlan(target, opts, args);
|
||||
let plan;
|
||||
try {
|
||||
plan = await buildRunPlan(target, opts, args);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
writeDryRunOutput(plan, opts);
|
||||
@@ -278,7 +564,11 @@ export async function runCliTarget(target, opts = {}, args = []) {
|
||||
return await runLaunchClaudeCommand(buildExecutionOptionsForClaude(opts), args);
|
||||
}
|
||||
|
||||
return await runLaunchCodexCommand(buildExecutionOptionsForCodex(opts), args);
|
||||
if (canonical === "codex") {
|
||||
return await runLaunchCodexCommand(buildExecutionOptionsForCodex(opts), args);
|
||||
}
|
||||
|
||||
return await runGenericTarget(canonical, opts, args);
|
||||
}
|
||||
|
||||
export function registerRun(program) {
|
||||
@@ -294,12 +584,15 @@ export function registerRun(program) {
|
||||
"--remote <url>",
|
||||
"Remote OmniRoute base URL (overrides --port, --base-url, and the active context)"
|
||||
)
|
||||
.option("--base-url <url>", "OmniRoute base URL (alias for --remote)")
|
||||
.option("--context <name>", "Named local/remote context to use for URL and credentials")
|
||||
.option("--provider <id>", "Provider id for shorthand model composition")
|
||||
.option("--model <id>", "Model id to inject in the launched target where supported")
|
||||
.option("--profile <name>", "Profile/alias argument for target launchers that support it")
|
||||
.option("-p, --p <name>", "Alias for --profile")
|
||||
.option("--token <token>", "Authentication token for the launched target (same as --api-key)")
|
||||
.option("--api-key <key>", "Authentication token for the launched target")
|
||||
.option("--api-key-env <name>", "Read the launch token from an environment variable")
|
||||
.option("--dry-run", "Show planned command and env keys without executing")
|
||||
.option("--json", "Return dry-run output in machine-readable format")
|
||||
.allowUnknownOption(true)
|
||||
|
||||
@@ -3,6 +3,108 @@ import { join, dirname } from "node:path";
|
||||
import { resolveDataDir } from "./data-dir.mjs";
|
||||
|
||||
const CONFIG_VERSION = 1;
|
||||
const KEYCHAIN_SERVICE = "omniroute-cli";
|
||||
const KEYCHAIN_DISABLED = /^(1|true|yes|on)$/i.test(
|
||||
String(process.env.OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED || "")
|
||||
);
|
||||
|
||||
// `keytar` is optional and native. Keeping it behind a small interface lets
|
||||
// headless installs use the same CLI without requiring libsecret/Keychain at
|
||||
// install time, while tests can inject a deterministic fake backend.
|
||||
let keychainBackend = null;
|
||||
let keychainOperational = true;
|
||||
let warnedPlaintextFallback = false;
|
||||
const credentialCache = new Map();
|
||||
|
||||
function isKeychainBackend(value) {
|
||||
return (
|
||||
value &&
|
||||
typeof value.getPassword === "function" &&
|
||||
typeof value.setPassword === "function" &&
|
||||
typeof value.deletePassword === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function loadKeychainBackend() {
|
||||
if (KEYCHAIN_DISABLED) return null;
|
||||
try {
|
||||
const imported = await import("keytar");
|
||||
const candidate = isKeychainBackend(imported?.default) ? imported.default : imported;
|
||||
return isKeychainBackend(candidate) ? candidate : null;
|
||||
} catch {
|
||||
// Native keychain modules are optional and commonly unavailable in
|
||||
// containers. The secure file fallback is handled explicitly below.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCredential(value) {
|
||||
if (!value || typeof value !== "string") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
const result = {};
|
||||
if (typeof parsed.accessToken === "string" && parsed.accessToken) {
|
||||
result.accessToken = parsed.accessToken;
|
||||
}
|
||||
if (typeof parsed.apiKey === "string" && parsed.apiKey) result.apiKey = parsed.apiKey;
|
||||
return result.accessToken || result.apiKey ? result : null;
|
||||
} catch {
|
||||
// Older/externally managed entries may contain one raw token.
|
||||
return { accessToken: value };
|
||||
}
|
||||
}
|
||||
|
||||
function credentialForContext(context) {
|
||||
const ref = context && typeof context.credentialRef === "string" ? context.credentialRef : "";
|
||||
return ref ? credentialCache.get(ref) || null : null;
|
||||
}
|
||||
|
||||
function applyCachedCredential(context) {
|
||||
const cached = credentialForContext(context);
|
||||
if (!cached) return { ...context };
|
||||
return { ...context, ...cached };
|
||||
}
|
||||
|
||||
async function hydrateCredentialCache(cfg) {
|
||||
if (!keychainBackend || !keychainOperational) return;
|
||||
const contexts = cfg?.contexts || cfg?.profiles || {};
|
||||
for (const context of Object.values(contexts)) {
|
||||
const ref = context && typeof context === "object" ? context.credentialRef : null;
|
||||
if (!ref || credentialCache.has(ref)) continue;
|
||||
try {
|
||||
const parsed = parseCredential(await keychainBackend.getPassword(KEYCHAIN_SERVICE, ref));
|
||||
if (parsed) credentialCache.set(ref, parsed);
|
||||
} catch {
|
||||
keychainOperational = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function warnPlaintextFallback() {
|
||||
if (warnedPlaintextFallback) return;
|
||||
warnedPlaintextFallback = true;
|
||||
process.stderr.write(
|
||||
"Warning: OS keychain unavailable; context credentials use config.json mode 0600 fallback.\n"
|
||||
);
|
||||
}
|
||||
|
||||
function readConfigFile() {
|
||||
try {
|
||||
if (!existsSync(configPath())) return defaultConfig();
|
||||
const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
|
||||
return parsed && typeof parsed === "object" ? parsed : defaultConfig();
|
||||
} catch {
|
||||
return defaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve keychain state before importing commands can call the synchronous
|
||||
// compatibility helpers below. Credentials themselves stay in memory; only a
|
||||
// stable reference is persisted in config.json when keytar is available.
|
||||
keychainBackend = await loadKeychainBackend();
|
||||
await hydrateCredentialCache(readConfigFile());
|
||||
|
||||
export function configPath() {
|
||||
return join(resolveDataDir(), "config.json");
|
||||
@@ -19,14 +121,13 @@ function defaultConfig() {
|
||||
}
|
||||
|
||||
export function loadContexts() {
|
||||
try {
|
||||
if (!existsSync(configPath())) return defaultConfig();
|
||||
return JSON.parse(readFileSync(configPath(), "utf8"));
|
||||
} catch {
|
||||
return defaultConfig();
|
||||
}
|
||||
return readConfigFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous compatibility writer. New credential-bearing code should use
|
||||
* `saveContextsSecure()` so tokens are moved to the OS keychain when possible.
|
||||
*/
|
||||
export function saveContexts(cfg) {
|
||||
const path = configPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
@@ -36,6 +137,116 @@ export function saveContexts(cfg) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Stable keychain reference; the reference itself is safe to persist in JSON. */
|
||||
export function contextCredentialRef(name) {
|
||||
return `${KEYCHAIN_SERVICE}:context:${encodeURIComponent(String(name))}`;
|
||||
}
|
||||
|
||||
/** Expose a non-secret capability status for diagnostics and tests. */
|
||||
export function getContextKeychainStatus() {
|
||||
return {
|
||||
available: Boolean(keychainBackend && keychainOperational),
|
||||
disabled: KEYCHAIN_DISABLED,
|
||||
fallback: !keychainBackend || !keychainOperational,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store context credentials through keytar and write only a credentialRef to
|
||||
* config.json. If keytar cannot be used, preserve the credential in the
|
||||
* mode-0600 file and emit one explicit warning instead of breaking headless
|
||||
* installs.
|
||||
*/
|
||||
export async function saveContextsSecure(cfg) {
|
||||
const source = cfg && typeof cfg === "object" ? cfg : defaultConfig();
|
||||
const next = JSON.parse(JSON.stringify(source));
|
||||
next.version = next.version || CONFIG_VERSION;
|
||||
if (!next.contexts && next.profiles) {
|
||||
next.contexts = next.profiles;
|
||||
delete next.profiles;
|
||||
}
|
||||
next.contexts = next.contexts || {};
|
||||
|
||||
for (const [name, raw] of Object.entries(next.contexts)) {
|
||||
const context = raw && typeof raw === "object" ? raw : {};
|
||||
const accessToken = typeof context.accessToken === "string" ? context.accessToken : "";
|
||||
const apiKey = typeof context.apiKey === "string" ? context.apiKey : "";
|
||||
const hasCredential = Boolean(accessToken || apiKey);
|
||||
|
||||
if (hasCredential && keychainBackend && keychainOperational) {
|
||||
const ref =
|
||||
typeof context.credentialRef === "string" && context.credentialRef
|
||||
? context.credentialRef
|
||||
: contextCredentialRef(name);
|
||||
try {
|
||||
await keychainBackend.setPassword(
|
||||
KEYCHAIN_SERVICE,
|
||||
ref,
|
||||
JSON.stringify({
|
||||
...(accessToken ? { accessToken } : {}),
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
})
|
||||
);
|
||||
credentialCache.set(ref, {
|
||||
...(accessToken ? { accessToken } : {}),
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
});
|
||||
context.credentialRef = ref;
|
||||
delete context.accessToken;
|
||||
delete context.apiKey;
|
||||
} catch {
|
||||
keychainOperational = false;
|
||||
warnPlaintextFallback();
|
||||
}
|
||||
} else if (hasCredential) {
|
||||
warnPlaintextFallback();
|
||||
}
|
||||
|
||||
next.contexts[name] = context;
|
||||
}
|
||||
|
||||
saveContexts(next);
|
||||
return {
|
||||
usedKeychain: Boolean(keychainBackend && keychainOperational),
|
||||
config: next,
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove the keychain entry associated with a context, if one exists. */
|
||||
export async function deleteContextCredential(name, context) {
|
||||
const cfg = loadContexts();
|
||||
const candidate = context || cfg.contexts?.[name] || cfg.profiles?.[name] || {};
|
||||
const ref = candidate.credentialRef || contextCredentialRef(name);
|
||||
credentialCache.delete(ref);
|
||||
if (!keychainBackend || !keychainOperational) return false;
|
||||
try {
|
||||
await keychainBackend.deletePassword(KEYCHAIN_SERVICE, ref);
|
||||
return true;
|
||||
} catch {
|
||||
keychainOperational = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicitly migrate legacy plaintext context credentials. */
|
||||
export async function migrateContextCredentials() {
|
||||
const cfg = loadContexts();
|
||||
const pending = Object.values(cfg.contexts || cfg.profiles || {}).some(
|
||||
(context) => context?.accessToken || context?.apiKey
|
||||
);
|
||||
if (!pending) return { migrated: false, pending: false, ...getContextKeychainStatus() };
|
||||
const result = await saveContextsSecure(cfg);
|
||||
return { migrated: result.usedKeychain, pending: true, ...getContextKeychainStatus() };
|
||||
}
|
||||
|
||||
/** Test-only backend injection; no secret is returned by this function. */
|
||||
export async function setContextKeychainBackendForTests(backend) {
|
||||
keychainBackend = isKeychainBackend(backend) ? backend : null;
|
||||
keychainOperational = true;
|
||||
credentialCache.clear();
|
||||
await hydrateCredentialCache(readConfigFile());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active context for a CLI invocation.
|
||||
*
|
||||
@@ -54,7 +265,13 @@ export function resolveActiveContext(overrideName) {
|
||||
const contexts = cfg.contexts || cfg.profiles || {};
|
||||
const name = overrideName || cfg.currentContext || cfg.activeProfile || "default";
|
||||
const found = contexts[name] || contexts.default;
|
||||
if (found) return found;
|
||||
if (found) return applyCachedCredential(found);
|
||||
if (cfg.baseUrl) return { baseUrl: cfg.baseUrl };
|
||||
return { baseUrl: `http://localhost:${process.env.PORT || "20128"}` };
|
||||
}
|
||||
|
||||
/** Async variant for callers that need to observe a just-created keychain entry. */
|
||||
export async function resolveActiveContextAsync(overrideName) {
|
||||
await hydrateCredentialCache(readConfigFile());
|
||||
return resolveActiveContext(overrideName);
|
||||
}
|
||||
|
||||
@@ -1300,7 +1300,7 @@
|
||||
"description": "Manage scoped CLI access tokens (remote mode)"
|
||||
},
|
||||
"configure": {
|
||||
"description": "Pick a provider+model from the active server and write a local CLI config"
|
||||
"description": "Pick a provider+model from the active server and configure a supported local CLI"
|
||||
},
|
||||
"launchCodex": {
|
||||
"description": "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"
|
||||
|
||||
@@ -1297,7 +1297,7 @@
|
||||
"description": "Gerencia tokens de acesso CLI com escopo (modo remoto)"
|
||||
},
|
||||
"configure": {
|
||||
"description": "Escolhe um provedor+modelo do servidor ativo e grava uma configuração de CLI local"
|
||||
"description": "Escolhe um provedor+modelo do servidor ativo e configura uma CLI local compatível"
|
||||
},
|
||||
"launchCodex": {
|
||||
"description": "Inicia o Codex CLI apontando para o OmniRoute (local ou VPS remoto)"
|
||||
|
||||
109
bin/cli/model-preferences.mjs
Normal file
109
bin/cli/model-preferences.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { resolveDataDir } from "./data-dir.mjs";
|
||||
|
||||
const PREFERENCES_VERSION = 1;
|
||||
const MAX_RECENT = 12;
|
||||
const MAX_FAVORITES = 32;
|
||||
|
||||
export function modelPreferencesPath() {
|
||||
return join(resolveDataDir(), "model-preferences.json");
|
||||
}
|
||||
|
||||
function defaultPreferences() {
|
||||
return { version: PREFERENCES_VERSION, targets: {}, contexts: {} };
|
||||
}
|
||||
|
||||
export function loadModelPreferences() {
|
||||
try {
|
||||
const path = modelPreferencesPath();
|
||||
if (!existsSync(path)) return defaultPreferences();
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return defaultPreferences();
|
||||
}
|
||||
return {
|
||||
version: PREFERENCES_VERSION,
|
||||
targets: parsed.targets && typeof parsed.targets === "object" ? parsed.targets : {},
|
||||
contexts: parsed.contexts && typeof parsed.contexts === "object" ? parsed.contexts : {},
|
||||
};
|
||||
} catch {
|
||||
return defaultPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
function saveModelPreferences(preferences) {
|
||||
const path = modelPreferencesPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(preferences, null, 2));
|
||||
try {
|
||||
chmodSync(path, 0o600);
|
||||
} catch {
|
||||
// Best effort on platforms without POSIX modes.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIds(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : []).filter((id) => typeof id === "string"))];
|
||||
}
|
||||
|
||||
function targetState(preferences, target, contextKey) {
|
||||
const raw = contextKey
|
||||
? preferences.contexts?.[contextKey]?.[target] ||
|
||||
(contextKey === "default" ? preferences.targets?.[target] : undefined)
|
||||
: preferences.targets?.[target];
|
||||
return {
|
||||
favorites: normalizeIds(raw?.favorites),
|
||||
recent: normalizeIds(raw?.recent),
|
||||
};
|
||||
}
|
||||
|
||||
function writeTargetState(preferences, target, contextKey) {
|
||||
if (!contextKey) {
|
||||
preferences.targets[target] = targetState(preferences, target);
|
||||
return preferences.targets[target];
|
||||
}
|
||||
preferences.contexts = preferences.contexts || {};
|
||||
preferences.contexts[contextKey] = preferences.contexts[contextKey] || {};
|
||||
preferences.contexts[contextKey][target] = targetState(preferences, target, contextKey);
|
||||
return preferences.contexts[contextKey][target];
|
||||
}
|
||||
|
||||
/** Rank catalog IDs with favorites first, then recent choices, then catalog order. */
|
||||
export function rankPreferredModels(
|
||||
target,
|
||||
modelIds,
|
||||
preferences = loadModelPreferences(),
|
||||
contextKey = ""
|
||||
) {
|
||||
const ids = normalizeIds(modelIds);
|
||||
const state = targetState(preferences, target, contextKey);
|
||||
const available = new Set(ids);
|
||||
const preferred = [...state.favorites, ...state.recent].filter((id) => available.has(id));
|
||||
return [...new Set([...preferred, ...ids])];
|
||||
}
|
||||
|
||||
/** Record a successful selection without storing server URLs or credentials. */
|
||||
export function recordModelPreference(target, modelId, options = {}) {
|
||||
if (!target || !modelId) return loadModelPreferences();
|
||||
const preferences = loadModelPreferences();
|
||||
const state = writeTargetState(preferences, target, options.context || "");
|
||||
state.recent = [modelId, ...state.recent.filter((id) => id !== modelId)].slice(0, MAX_RECENT);
|
||||
if (options.favorite) {
|
||||
state.favorites = [modelId, ...state.favorites.filter((id) => id !== modelId)].slice(
|
||||
0,
|
||||
MAX_FAVORITES
|
||||
);
|
||||
}
|
||||
if (options.unfavorite) state.favorites = state.favorites.filter((id) => id !== modelId);
|
||||
saveModelPreferences(preferences);
|
||||
return preferences;
|
||||
}
|
||||
|
||||
export function getModelPreferenceState(
|
||||
target,
|
||||
preferences = loadModelPreferences(),
|
||||
contextKey = ""
|
||||
) {
|
||||
return targetState(preferences, target, contextKey);
|
||||
}
|
||||
@@ -18,6 +18,23 @@ There are also two launchers — `omniroute launch` (Claude Code) and
|
||||
`omniroute launch-codex` (Codex) — that spawn the CLI with the right env injected,
|
||||
without writing any config at all.
|
||||
|
||||
Provider onboarding is available from the same local/remote context. The
|
||||
API-first commands below keep management authentication separate from provider
|
||||
credentials and never print a credential in structured output:
|
||||
|
||||
```bash
|
||||
omniroute providers add glm --credential-env GLM_API_KEY --name work
|
||||
omniroute providers import ./providers.json --dry-run --json
|
||||
omniroute providers auth openai
|
||||
omniroute providers edit <connection-id> --default-model glm/glm-5.2
|
||||
omniroute providers remove <connection-id> --yes
|
||||
```
|
||||
|
||||
For scripts, prefer `--credential-stdin` or `--credential-env`; `--credential`
|
||||
is retained for controlled local use. `providers remove` requires `--yes` on a
|
||||
non-interactive terminal, and all five commands honor the active context or the
|
||||
global `--base-url`/`--api-key` options.
|
||||
|
||||
For the one-time, hand-written base setup of the two richest integrations, see the
|
||||
per-tool deep dives:
|
||||
|
||||
@@ -37,23 +54,23 @@ Every command honours the **active context** (set with `omniroute connect`, see
|
||||
with `--remote` (or an active remote context) it fetches the catalog from that
|
||||
server and writes the config locally.
|
||||
|
||||
| Command | Tool | What it writes | Key flags | Local vs remote |
|
||||
| -------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------- |
|
||||
| `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/<name>.config.toml` — one profile per compatible text model (`codex --profile <name>`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both |
|
||||
| `omniroute setup-claude` | Claude Code | `~/.claude/profiles/<name>/settings.json` — one profile per matched model (`CLAUDE_CONFIG_DIR`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--claude-home` | Both |
|
||||
| `omniroute setup-opencode` | OpenCode (openai-compatible) | `~/.config/opencode/opencode.json` — `omniroute` provider with every catalog model (`opencode -m omniroute/<model>`) | `--remote` `--api-key` `--only` `--model` `--dry-run` `--port` | Both |
|
||||
| `omniroute setup-cline` | Cline | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints VS Code extension settings | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--cline-dir` | Both |
|
||||
| `omniroute setup-kilo` | Kilo Code | `~/.local/share/kilo/auth.json` (CLI) + merges `kilocode.*` into VS Code `settings.json` if present | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--auth-path` `--vscode-settings` | Both |
|
||||
| `omniroute setup-continue` | Continue / `cn` CLI | `~/.continue/config.yaml` — `provider: openai` models, key via `${{ secrets.OMNIROUTE_API_KEY }}` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
|
||||
| `omniroute setup-cursor` | Cursor | Nothing — prints the in-app steps (Cursor config is opaque SQLite) | `--remote` `--api-key` `--only` `--port` | Both |
|
||||
| `omniroute setup-roo` | Roo Code | `~/.omniroute/roo-settings.json` (import doc) + sets `roo-cline.autoImportSettingsPath` if a VS Code `settings.json` exists | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--import-path` `--vscode-settings` | Both |
|
||||
| `omniroute setup-crush` | Crush | `~/.config/crush/crush.json` — `openai-compat` provider, key via `$OMNIROUTE_API_KEY` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
|
||||
| `omniroute setup-goose` | Goose | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER`/`OPENAI_HOST`/`GOOSE_MODEL`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
|
||||
| `omniroute setup-aider` | Aider | `~/.aider.conf.yml` (`openai-api-base` + `model: openai/<id>`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
|
||||
| `omniroute setup-qwen` | Qwen Code | `~/.qwen/settings.json` — V4 `modelProviders.openai` array + `OMNIROUTE_API_KEY` in `~/.qwen/.env` | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` `--env-path` | Both |
|
||||
| `omniroute run <target>` | Runtime launch (generic) | Nothing — spawn `claude`/`codex` with the right env and args | `--remote` `--base-url` `--context` `--provider` `--model` `--api-key` `--dry-run` `--json` `--port` `--profile` `--token` | Both |
|
||||
| `omniroute launch` | Claude Code | Nothing — spawns `claude` with `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` injected | `--remote` `--api-key` `--token` `--profile` `--port` | Both |
|
||||
| `omniroute launch-codex` | OpenAI Codex CLI | Nothing — spawns `codex` with the `omniroute` provider injected via `-c` flags | `--remote` `--api-key` `--profile` (`-p`) `--port` | Both |
|
||||
| Command | Tool | What it writes | Key flags | Local vs remote |
|
||||
| -------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
|
||||
| `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/<name>.config.toml` — one profile per compatible text model (`codex --profile <name>`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both |
|
||||
| `omniroute setup-claude` | Claude Code | `~/.claude/profiles/<name>/settings.json` — one profile per matched model (`CLAUDE_CONFIG_DIR`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--claude-home` | Both |
|
||||
| `omniroute setup-opencode` | OpenCode (openai-compatible) | `~/.config/opencode/opencode.json` — `omniroute` provider with every catalog model (`opencode -m omniroute/<model>`) | `--remote` `--api-key` `--only` `--model` `--dry-run` `--port` | Both |
|
||||
| `omniroute setup-cline` | Cline | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints VS Code extension settings | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--cline-dir` | Both |
|
||||
| `omniroute setup-kilo` | Kilo Code | `~/.local/share/kilo/auth.json` (CLI) + merges `kilocode.*` into VS Code `settings.json` if present | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--auth-path` `--vscode-settings` | Both |
|
||||
| `omniroute setup-continue` | Continue / `cn` CLI | `~/.continue/config.yaml` — `provider: openai` models, key via `${{ secrets.OMNIROUTE_API_KEY }}` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
|
||||
| `omniroute setup-cursor` | Cursor | Nothing — prints the in-app steps (Cursor config is opaque SQLite) | `--remote` `--api-key` `--only` `--port` | Both |
|
||||
| `omniroute setup-roo` | Roo Code | `~/.omniroute/roo-settings.json` (import doc) + sets `roo-cline.autoImportSettingsPath` if a VS Code `settings.json` exists | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--import-path` `--vscode-settings` | Both |
|
||||
| `omniroute setup-crush` | Crush | `~/.config/crush/crush.json` — `openai-compat` provider, key via `$OMNIROUTE_API_KEY` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
|
||||
| `omniroute setup-goose` | Goose | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER`/`OPENAI_HOST`/`GOOSE_MODEL`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
|
||||
| `omniroute setup-aider` | Aider | `~/.aider.conf.yml` (`openai-api-base` + `model: openai/<id>`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
|
||||
| `omniroute setup-qwen` | Qwen Code | `~/.qwen/settings.json` — V4 `modelProviders.openai` array + `OMNIROUTE_API_KEY` in `~/.qwen/.env` | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` `--env-path` | Both |
|
||||
| `omniroute run <target>` | Runtime launch (generic) | Nothing — spawn `claude`/`codex`/`aider`/`goose`/`opencode`/`qwen` with the right env and args; Qwen uses a temporary isolated home | `--remote` `--base-url` `--context` `--provider` `--model` `--api-key` `--api-key-env` `--dry-run` `--json` `--port` `--profile` `--token` | Both |
|
||||
| `omniroute launch` | Claude Code | Nothing — spawns `claude` with `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` injected | `--remote` `--api-key` `--token` `--profile` `--port` | Both |
|
||||
| `omniroute launch-codex` | OpenAI Codex CLI | Nothing — spawns `codex` with the `omniroute` provider injected via `-c` flags | `--remote` `--api-key` `--profile` (`-p`) `--port` | Both |
|
||||
|
||||
Notes on flags (verified in the command source):
|
||||
|
||||
@@ -76,6 +93,20 @@ Notes on flags (verified in the command source):
|
||||
a profile written by `setup-claude` / `setup-codex`, plus pass-through args for
|
||||
the underlying `claude` / `codex` binary.
|
||||
|
||||
The interactive picker is also shared by the setup recipes:
|
||||
|
||||
```bash
|
||||
# Pick from the active local or remote model catalog and configure the target.
|
||||
omniroute configure claude
|
||||
omniroute configure opencode --provider glm
|
||||
omniroute configure qwen --model qwen/qwen3.8-max-preview --yes
|
||||
```
|
||||
|
||||
`configure` currently delegates to the tested recipes for `codex`, `claude`,
|
||||
`opencode`, `qwen`, `aider`, `goose`, `cline`, `continue`, and `kilo`. IDE-only,
|
||||
MITM, and guide-only catalog entries remain explicit `setup-*`/manual flows and
|
||||
are not presented as launchable targets.
|
||||
|
||||
> `setup-opencode` is the **lightweight openai-compatible** OpenCode integration.
|
||||
> There is also a richer plugin integration — `omniroute setup opencode` — which
|
||||
> installs `@omniroute/opencode-plugin`. They are different commands; the table
|
||||
@@ -118,6 +149,10 @@ omniroute launch-codex # Codex CLI → local OmniRoute
|
||||
omniroute launch-codex --profile glm52
|
||||
omniroute run claude --model openai/gpt-5.4
|
||||
omniroute run codex --model openai/gpt-5.4 --dry-run --json
|
||||
omniroute run aider --model glm/glm-5.2 -- --message "reply OK"
|
||||
omniroute run goose --model glm/glm-5.2
|
||||
omniroute run opencode --model glm/glm-5.2 -- run "reply OK"
|
||||
omniroute run qwen --model glm/glm-5.2 -- -p "reply OK"
|
||||
|
||||
# Explicit command path: pass through whatever comes after --
|
||||
omniroute run claude -- --print-system-prompt "review this diff"
|
||||
@@ -202,6 +237,56 @@ outdated), `--apply` (install without prompting), `--changelog`, `--no-backup`,
|
||||
|
||||
---
|
||||
|
||||
## Google Gemini CLI via `omniroute run gemini`
|
||||
|
||||
Contract verified against `@google/gemini-cli` 0.50.0: the CLI honors
|
||||
`GOOGLE_GEMINI_BASE_URL` and issues `POST /v1beta/models/<model>:generateContent`
|
||||
(and `:streamGenerateContent?alt=sse`) against it — exactly OmniRoute's native
|
||||
Gemini surface (`/v1beta`). `omniroute run gemini` wires that automatically:
|
||||
|
||||
- `GOOGLE_GEMINI_BASE_URL` → the active OmniRoute base URL (root, no `/v1`);
|
||||
- `GEMINI_API_KEY` → the resolved OmniRoute credential (option/env/context);
|
||||
- a **temporary isolated `GEMINI_CLI_HOME`** whose `.gemini/settings.json`
|
||||
selects `gemini-api-key` auth, so a stored Google OAuth session (Code Assist)
|
||||
never overrides the OmniRoute-directed launch — removed after exit;
|
||||
- `--model <id>` injection from `--provider`/`--model`.
|
||||
|
||||
```bash
|
||||
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "hello"
|
||||
```
|
||||
|
||||
Gemini's workspace-trust guard still applies in headless mode — pass
|
||||
`--skip-trust` (or trust the directory interactively) yourself; the launcher
|
||||
deliberately does not bypass it. This launcher is distinct from the **ACP
|
||||
registration** (`src/lib/acp/registry.ts`, `gemini --acp`), which remains the
|
||||
agent-protocol integration for `/dashboard/acp-agents`.
|
||||
|
||||
---
|
||||
|
||||
## Real smoke sweep (opt-in)
|
||||
|
||||
Deterministic launch-plan regression runs in CI (`tests/unit/cli/run-command.test.ts`,
|
||||
`tests/unit/cli/run-execution.test.ts`). To validate the REAL binaries against a REAL
|
||||
OmniRoute server, an opt-in harness exists at
|
||||
`tests/integration/upstream-cli-smoke.int.test.ts`. It never runs automatically
|
||||
(every sub-test skips unless `RUN_CLI_SMOKE=1`), passes the credential by env-var
|
||||
NAME (never by value), redacts key-shaped strings from any recorded output, skips
|
||||
targets whose binary is not installed, and classifies failures as
|
||||
auth / upstream / config instead of a bare boolean:
|
||||
|
||||
```bash
|
||||
RUN_CLI_SMOKE=1 \
|
||||
OMNIROUTE_SMOKE_BASE_URL="http://localhost:20128" \
|
||||
OMNIROUTE_SMOKE_MODEL="<provider/model>" \
|
||||
OMNIROUTE_SMOKE_API_KEY_ENV="OMNIROUTE_API_KEY" \
|
||||
node --import tsx/esm --test tests/integration/upstream-cli-smoke.int.test.ts
|
||||
```
|
||||
|
||||
Optional: `OMNIROUTE_SMOKE_TARGETS="codex,opencode,qwen"` restricts the sweep;
|
||||
`OMNIROUTE_SMOKE_TIMEOUT_MS` overrides the 120s per-target timeout.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [Claude Code configuration](./CLAUDE-CODE-CONFIGURATION.md) — the deeper Claude Code guide
|
||||
|
||||
@@ -10,6 +10,15 @@ Complete guide for using the Codex CLI pointed at OmniRoute as an OpenAI-compati
|
||||
|
||||
---
|
||||
|
||||
> **TOML is the only effective format.** Modern Codex reads `~/.codex/config.toml`
|
||||
> exclusively (verified against codex-cli 0.147.0: `codex --help` documents
|
||||
> `-c/--config` overrides "loaded from `~/.codex/config.toml`"). The old
|
||||
> `~/.codex/config.yaml` belonged to the legacy npm CLI and is silently ignored.
|
||||
> The dashboard generator (`/api/cli-tools/apply`, tool `codex`) writes TOML with a
|
||||
> conservative merge — existing keys and other provider blocks are preserved, the
|
||||
> API key stays in `OMNIROUTE_API_KEY` (never in the file), and a leftover legacy
|
||||
> `config.yaml` is reported as a migration note without being touched.
|
||||
|
||||
## Ready-to-paste config.toml
|
||||
|
||||
Replace `<YOUR_HOST>` and `<YOUR_KEY>` with your values:
|
||||
|
||||
@@ -271,8 +271,16 @@ omniroute configure codex
|
||||
|
||||
# non-interactive
|
||||
omniroute configure codex --provider glm --model glm/glm-5.2 --name glm52
|
||||
|
||||
# keep a frequently used model at the top of the interactive picker
|
||||
omniroute configure codex --provider glm --model glm/glm-5.2 --favorite --yes
|
||||
```
|
||||
|
||||
The picker keeps only model IDs (never URLs or credentials) in the local
|
||||
`model-preferences.json` file, scoped by context and CLI target. Favorites are
|
||||
shown before recent selections; use `--unfavorite` to remove a selected model
|
||||
from that context/target list.
|
||||
|
||||
The written profile references the inference key by env var
|
||||
(`OMNIROUTE_API_KEY`) — the secret is never written to disk. For the one-time
|
||||
base Codex setup (the `[model_providers.omniroute]` block), see
|
||||
@@ -360,14 +368,20 @@ omniroute contexts remove stg --yes
|
||||
> revoke the token on the server with `omniroute tokens revoke <id>` to actually
|
||||
> kill access.
|
||||
|
||||
**Export / import** contexts (e.g. to move them between machines — secrets included,
|
||||
so handle the file carefully):
|
||||
**Export / import** contexts (e.g. to move them between machines). New contexts persist
|
||||
only a keychain reference; credentials are not copied into the export when the OS
|
||||
keychain is available:
|
||||
|
||||
```bash
|
||||
omniroute contexts export --out contexts.json # default: stdout
|
||||
omniroute contexts import contexts.json # overwrite; --merge to keep existing
|
||||
omniroute contexts migrate --yes # move legacy plaintext tokens to keychain
|
||||
```
|
||||
|
||||
On headless systems without a usable OS keychain, the CLI falls back to
|
||||
`config.json` with mode `0600` and prints a one-time warning. Treat exports from
|
||||
that fallback (and any legacy config before migration) as secret material.
|
||||
|
||||
---
|
||||
|
||||
## Quick end-to-end check
|
||||
@@ -409,8 +423,12 @@ omniroute contexts remove 192-168-0-15 --yes # drop the local context (even if
|
||||
- `omniroute connect` reuses the login brute-force lockout + audit logging.
|
||||
- Prefer HTTPS or a Tailnet for the transport; a bare host defaults to `http://`
|
||||
for LAN/Tailscale convenience — pass a full `https://…` URL for TLS.
|
||||
- The local context file is `~/.omniroute/config.json` (`chmod 600`); tokens are
|
||||
never printed in logs (masked to a prefix).
|
||||
- The preferred local context file is `~/.omniroute/config.json` (`chmod 600`)
|
||||
containing only a `credentialRef`; the token itself is stored in the OS
|
||||
keychain (`keytar`) and is never printed in logs. Headless installs without a
|
||||
working native keychain use the same `0600` file as an explicit fallback and
|
||||
emit a warning once. Use `omniroute contexts migrate --yes` after installing a
|
||||
keychain backend.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -83,6 +83,17 @@ actually meant; `OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` does the same for
|
||||
the server. See
|
||||
[Docker Guide → Configuring host CLI tools](../guides/DOCKER_GUIDE.md#configuring-host-cli-tools-when-omniroute-runs-in-docker).
|
||||
|
||||
The dashboard's **apply endpoint** (`POST /api/cli-tools/apply`) enforces the
|
||||
same guard: in a container, a write whose target is not bind-mounted from the
|
||||
host answers **`422`** with `containerEphemeralTarget: true`, the safe error
|
||||
text and a `hostSetupCommand` (e.g. `omniroute setup-opencode`) to run on the
|
||||
host instead — nothing is written. `dryRun: true` keeps working in container
|
||||
mode and returns the generated content + target path without touching disk, so
|
||||
you can preview from the dashboard and apply on the host. This behavior is
|
||||
intentional and regression-guarded by
|
||||
`tests/unit/api/cli-tools/apply-container-guard.test.ts` — never "fix" a 422
|
||||
by removing the guard.
|
||||
|
||||
---
|
||||
|
||||
## Source of Truth
|
||||
@@ -102,6 +113,26 @@ Each entry has these fields (defined in `src/shared/schemas/cliCatalog.ts`):
|
||||
|
||||
Entries with `baseUrlSupport: "none"` are **not shown** in the dashboard pages — they are registered in the MITM backlog for plan 11 (see `_tasks/features-v3.8.6/refactorpages/_orchestration/_plan11-mitm-backlog.md`).
|
||||
|
||||
### Capability tiers (cataloged × detectable × configurable × launchable)
|
||||
|
||||
Not every cataloged tool is detectable, configurable or launchable. Each tier has one
|
||||
declaring source, and a drift test keeps them aligned:
|
||||
|
||||
| Tier | Meaning | Declared in |
|
||||
| ---------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| **Cataloged** | Appears in the dashboard catalog (name, vendor, docs, config type) | `src/shared/constants/cliTools.ts` (`CLI_TOOLS`) |
|
||||
| **Detectable** | Binary/config detection, health checks, config paths | `src/shared/services/cliRuntime.ts` (`CLI_TOOLS` runtime catalog) |
|
||||
| **Configurable** | Supported by `omniroute configure <cli>` (setup recipe exists) | `bin/cli/cli-manifest.mjs` (`configure: true`) |
|
||||
| **Launchable** | Supported by `omniroute run <target>` (env/args injection defined) | `bin/cli/cli-manifest.mjs` (`run: true`) |
|
||||
|
||||
`bin/cli/cli-manifest.mjs` is the canonical executable manifest for the CLI command
|
||||
surfaces: `run`, `configure` and the shell-completion generators all derive their
|
||||
target lists, alias resolution (for example `kilocode`/`kilo-code`/`kilo_cli` → `kilo`)
|
||||
and `--model` flag wiring from it. The drift guard
|
||||
`tests/unit/cli/cli-manifest-drift.test.ts` asserts that the manifest, the runtime
|
||||
catalog, the UI catalog and every consumer surface stay in sync — a target added to
|
||||
one surface without the others fails the suite instead of drifting silently.
|
||||
|
||||
---
|
||||
|
||||
## 1. CLI Code's Catalog (25 tools)
|
||||
@@ -384,14 +415,26 @@ Use the unified Anthropic gateway root for Claude Code. Do not append `/v1` here
|
||||
|
||||
#### OpenAI Codex
|
||||
|
||||
Modern Codex (v0.137+) reads `~/.codex/config.toml` only — the old
|
||||
`config.yaml` belongs to the legacy npm CLI and is silently ignored. The API
|
||||
key stays in the `OMNIROUTE_API_KEY` environment variable (`env_key`), never
|
||||
inside the file:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.codex && cat > ~/.codex/config.yaml << EOF
|
||||
model: auto
|
||||
apiKey: sk-your-omniroute-key
|
||||
apiBaseUrl: http://localhost:20128/v1
|
||||
mkdir -p ~/.codex && cat > ~/.codex/config.toml << EOF
|
||||
model_provider = "omniroute"
|
||||
|
||||
[model_providers.omniroute]
|
||||
name = "OmniRoute"
|
||||
base_url = "http://localhost:20128/v1"
|
||||
env_key = "OMNIROUTE_API_KEY"
|
||||
requires_openai_auth = false
|
||||
EOF
|
||||
export OMNIROUTE_API_KEY="sk-your-omniroute-key"
|
||||
```
|
||||
|
||||
Full reference (profiles, `wire_api`, context windows): [CODEX-CLI-CONFIGURATION.md](../guides/CODEX-CLI-CONFIGURATION.md).
|
||||
|
||||
**Test:** `codex "what is 2+2?"`
|
||||
|
||||
---
|
||||
@@ -613,10 +656,19 @@ omniroute providers list --json
|
||||
omniroute providers test <id|name> # Test one configured connection
|
||||
omniroute providers test-all # Test every active connection
|
||||
omniroute providers validate # Local-only structural validation
|
||||
omniroute providers add <provider> --credential-env PROVIDER_KEY
|
||||
omniroute providers import ./providers.json --dry-run --json
|
||||
omniroute providers auth <provider> # Existing OAuth flow
|
||||
omniroute providers edit <id|name> --default-model <model>
|
||||
omniroute providers remove <id|name> --yes
|
||||
```
|
||||
|
||||
> `providers available` reads the OmniRoute catalog; `providers list/test/test-all/validate`
|
||||
> read the local SQLite database directly and do not require the server to be running.
|
||||
`providers add/import/auth/edit/remove` are API-first and therefore work against
|
||||
the active local or remote context. Credential input should use
|
||||
`--credential-stdin` or `--credential-env`; `--dry-run --json` reports only
|
||||
redacted presence/shape. `providers available` reads the OmniRoute catalog;
|
||||
`providers list/test/test-all/validate` retain their local SQLite behavior and
|
||||
do not require the server to be running.
|
||||
|
||||
### Recovery & Reset
|
||||
|
||||
|
||||
@@ -474,6 +474,7 @@ detection above).
|
||||
| `OMNIROUTE_ISSUE_AGENT_ENABLED` | `false` | `src/app/api/issue-agent/runs/route.ts` | Enables the offline/local Issue Agent recorded-triage endpoint. Leave disabled unless explicitly running local recorded-triage workflows. |
|
||||
| `OMNIROUTE_ISSUE_AGENT_TIMEOUT_MS` | _(unset)_ | `src/lib/issueAgent/execution.ts` | Timeout (ms) for a single Issue Agent recorded-triage run. Clamped to an internal maximum; falls back to the built-in default when unset or invalid. |
|
||||
| `OMNIROUTE_CONTEXT` | _(active context)_ | `bin/cli/program.mjs`, `bin/cli/api.mjs` | CLI remote-mode context/profile for `omniroute` commands; overrides the active context in the local contexts store. Equivalent to `--context <name>`. |
|
||||
| `OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED` | `0` | `bin/cli/contexts.mjs` | Disable the optional `keytar` OS-keychain backend for CLI context credentials. When enabled, credentials remain in `config.json` mode `0600` and the CLI emits a one-time fallback warning; intended for deliberate headless/container operation. |
|
||||
| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `true` | `open-sse/mcp-server/server.ts` | Enforce scope-based access control on MCP tool calls. |
|
||||
| `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Comma-separated scopes: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. |
|
||||
| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | `false` | `open-sse/mcp-server/descriptionCompressor.ts` | Compress MCP tool descriptions before serializing the manifest. Enable values: `1`, `true`, `on`. |
|
||||
|
||||
@@ -117,15 +117,24 @@ async function applyOmniglyph(
|
||||
let outBody: Record<string, unknown>;
|
||||
try {
|
||||
const encoded = new TextEncoder().encode(JSON.stringify(body));
|
||||
const result =
|
||||
wireFormat === "claude"
|
||||
? await transformAnthropicMessages({ body: encoded, model })
|
||||
: wireFormat === "openai"
|
||||
// Branch explicitly so TS narrows each transformer's return type:
|
||||
// the Anthropic wrapper reports `applied`, the OpenAI ones `info.compressed`.
|
||||
let applied: boolean;
|
||||
let transformed: { body: Uint8Array; info: { compressed: boolean; reason?: string } };
|
||||
if (wireFormat === "claude") {
|
||||
const result = await transformAnthropicMessages({ body: encoded, model });
|
||||
transformed = result;
|
||||
applied = result.applied;
|
||||
} else {
|
||||
const result =
|
||||
wireFormat === "openai"
|
||||
? await transformOpenAIChatCompletions(encoded)
|
||||
: await transformOpenAIResponses(encoded);
|
||||
const applied = wireFormat === "claude" ? result.applied : result.info.compressed;
|
||||
if (!applied) return skip(body, result.info?.reason ?? "not_profitable");
|
||||
outBody = JSON.parse(new TextDecoder().decode(result.body)) as Record<string, unknown>;
|
||||
transformed = result;
|
||||
applied = result.info.compressed;
|
||||
}
|
||||
if (!applied) return skip(body, transformed.info?.reason ?? "not_profitable");
|
||||
outBody = JSON.parse(new TextDecoder().decode(transformed.body)) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Fail-open: qualquer erro no encode/transform/decode (ex.: corpo não serializável,
|
||||
// render PNG estourando, JSON decodificado malformado) vira skip, nunca propaga.
|
||||
|
||||
@@ -12,6 +12,62 @@ import {
|
||||
} from "@/lib/usage/tokenAccounting";
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
|
||||
/** Nested `*_tokens_details` containers ({ cached_tokens, reasoning_tokens, … }). */
|
||||
interface UsageTokenDetail {
|
||||
cached_tokens?: number;
|
||||
reasoning_tokens?: number;
|
||||
thinking_tokens?: number;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loosely-shaped usage object accepted from any provider wire format.
|
||||
* Declared fields cover the numeric counters this module reads/writes;
|
||||
* everything else passes through untouched via the index signature.
|
||||
*/
|
||||
export interface UsageLike {
|
||||
estimated?: boolean;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
cached_tokens?: number;
|
||||
no_cache_tokens?: number;
|
||||
reasoning_tokens?: number;
|
||||
cost_in_usd_ticks?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
prompt_cache_hit_tokens?: number;
|
||||
prompt_cache_miss_tokens?: number;
|
||||
promptTokenCount?: number;
|
||||
candidatesTokenCount?: number;
|
||||
totalTokenCount?: number;
|
||||
cachedContentTokenCount?: number;
|
||||
thoughtsTokenCount?: number;
|
||||
context_budget_input_tokens?: number;
|
||||
context_budget_prompt_tokens?: number;
|
||||
context_budget_total_tokens?: number;
|
||||
prompt_tokens_details?: UsageTokenDetail;
|
||||
input_tokens_details?: UsageTokenDetail;
|
||||
completion_tokens_details?: UsageTokenDetail;
|
||||
output_tokens_details?: UsageTokenDetail;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
/** SSE/JSON chunk shapes this module inspects for embedded usage containers. */
|
||||
interface UsagePayloadLike {
|
||||
type?: string;
|
||||
done?: boolean;
|
||||
prompt_eval_count?: number;
|
||||
eval_count?: number;
|
||||
usage?: UsageLike;
|
||||
usageMetadata?: UsageLike;
|
||||
message?: { usage?: UsageLike; [field: string]: unknown };
|
||||
response?: { usage?: UsageLike; usageMetadata?: UsageLike; [field: string]: unknown };
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
// ANSI color codes
|
||||
export const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
@@ -127,7 +183,7 @@ function getTimeString() {
|
||||
* @param {object} usage - Usage object (supported format)
|
||||
* @returns {object} Usage with context_budget_* fields added (metering fields unchanged)
|
||||
*/
|
||||
export function addBufferToUsage(usage) {
|
||||
export function addBufferToUsage(usage: UsageLike | null | undefined) {
|
||||
if (!usage || typeof usage !== "object") return usage;
|
||||
|
||||
// Heuristic estimates (web/cookie providers with no upstream metering) should
|
||||
@@ -164,7 +220,7 @@ export function addBufferToUsage(usage) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function filterUsageForFormat(usage, targetFormat) {
|
||||
export function filterUsageForFormat(usage: UsageLike | null | undefined, targetFormat: string) {
|
||||
if (!usage || typeof usage !== "object") return usage;
|
||||
|
||||
// Cross-map between Claude-style and OpenAI-style field names before filtering.
|
||||
@@ -211,8 +267,8 @@ export function filterUsageForFormat(usage, targetFormat) {
|
||||
}
|
||||
|
||||
// Helper to pick only defined fields from usage
|
||||
const pickFields = (fields) => {
|
||||
const filtered = {};
|
||||
const pickFields = (fields: string[]) => {
|
||||
const filtered: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
if (convertedUsage[field] !== undefined) {
|
||||
filtered[field] = convertedUsage[field];
|
||||
@@ -222,7 +278,7 @@ export function filterUsageForFormat(usage, targetFormat) {
|
||||
};
|
||||
|
||||
// Define allowed fields for each format
|
||||
const formatFields = {
|
||||
const formatFields: Record<string, string[]> = {
|
||||
[FORMATS.CLAUDE]: [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
@@ -312,7 +368,7 @@ const REMOTE_CONTEXT_REFERENCE_KEYS = new Set([
|
||||
"videoUrl",
|
||||
]);
|
||||
|
||||
function hasValue(value): boolean {
|
||||
function hasValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined || value === false) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
@@ -320,7 +376,7 @@ function hasValue(value): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasRemoteContextReference(value, depth = 0): boolean {
|
||||
function hasRemoteContextReference(value: unknown, depth = 0): boolean {
|
||||
if (!value || typeof value !== "object" || depth > 8) return false;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
@@ -338,7 +394,7 @@ function hasRemoteContextReference(value, depth = 0): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSerializedBodyBytes(body): number | null {
|
||||
function getSerializedBodyBytes(body: unknown): number | null {
|
||||
if (!body || typeof body !== "object" || hasRemoteContextReference(body)) return null;
|
||||
try {
|
||||
const serialized = JSON.stringify(body);
|
||||
@@ -349,7 +405,7 @@ function getSerializedBodyBytes(body): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
function tokenNumber(value): number {
|
||||
function tokenNumber(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
@@ -357,7 +413,7 @@ function tokenNumber(value): number {
|
||||
* Return true when a provider-reported input count is plausible for this request.
|
||||
* `null`/unserializable bodies and server-side context references fail open.
|
||||
*/
|
||||
export function isInputTokenCountPlausible(inputTokens, body): boolean {
|
||||
export function isInputTokenCountPlausible(inputTokens: unknown, body: unknown): boolean {
|
||||
if (typeof inputTokens !== "number" || !Number.isFinite(inputTokens) || inputTokens < 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -368,7 +424,7 @@ export function isInputTokenCountPlausible(inputTokens, body): boolean {
|
||||
return inputTokens <= maximum;
|
||||
}
|
||||
|
||||
function resolveUsageFormat(usage, targetFormat) {
|
||||
function resolveUsageFormat(usage: UsageLike | null | undefined, targetFormat: string | null) {
|
||||
if (targetFormat === FORMATS.CLAUDE) return FORMATS.CLAUDE;
|
||||
if (targetFormat === FORMATS.GEMINI || targetFormat === FORMATS.ANTIGRAVITY) {
|
||||
return FORMATS.GEMINI;
|
||||
@@ -391,7 +447,7 @@ function resolveUsageFormat(usage, targetFormat) {
|
||||
return FORMATS.OPENAI;
|
||||
}
|
||||
|
||||
function getReportedInputTokens(usage, format): number {
|
||||
function getReportedInputTokens(usage: UsageLike, format: string): number {
|
||||
if (format === FORMATS.CLAUDE) {
|
||||
return (
|
||||
tokenNumber(usage.input_tokens) +
|
||||
@@ -408,7 +464,7 @@ function getReportedInputTokens(usage, format): number {
|
||||
return tokenNumber(usage.prompt_tokens ?? usage.input_tokens);
|
||||
}
|
||||
|
||||
function clearCachedTokenDetail(value) {
|
||||
function clearCachedTokenDetail<T extends UsageTokenDetail | null | undefined>(value: T): T {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const result = { ...value };
|
||||
if (result.cached_tokens !== undefined) result.cached_tokens = 0;
|
||||
@@ -419,7 +475,11 @@ function clearCachedTokenDetail(value) {
|
||||
* Replace only physically implausible provider input/cache usage with the local
|
||||
* request estimate. Valid usage is returned by reference and remains untouched.
|
||||
*/
|
||||
export function sanitizeProviderUsageForRequest(usage, body, targetFormat = null) {
|
||||
export function sanitizeProviderUsageForRequest(
|
||||
usage: UsageLike | null | undefined,
|
||||
body: unknown,
|
||||
targetFormat: string | null = null
|
||||
) {
|
||||
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return usage;
|
||||
|
||||
const format = resolveUsageFormat(usage, targetFormat);
|
||||
@@ -475,12 +535,20 @@ export function sanitizeProviderUsageForRequest(usage, body, targetFormat = null
|
||||
* Sanitize the usage container used by native provider responses/SSE events.
|
||||
* Returns true only when the payload was changed and must be re-serialized.
|
||||
*/
|
||||
export function sanitizeUsagePayloadForRequest(payload, body, targetFormat = null): boolean {
|
||||
export function sanitizeUsagePayloadForRequest(
|
||||
payload: UsagePayloadLike | null | undefined,
|
||||
body: unknown,
|
||||
targetFormat: string | null = null
|
||||
): boolean {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
||||
|
||||
const replaceUsage = (owner, key, format) => {
|
||||
const replaceUsage = (
|
||||
owner: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
format: string | null
|
||||
) => {
|
||||
if (!owner || typeof owner !== "object" || !owner[key]) return false;
|
||||
const sanitized = sanitizeProviderUsageForRequest(owner[key], body, format);
|
||||
const sanitized = sanitizeProviderUsageForRequest(owner[key] as UsageLike, body, format);
|
||||
if (sanitized === owner[key]) return false;
|
||||
owner[key] = sanitized;
|
||||
return true;
|
||||
@@ -511,11 +579,11 @@ export function sanitizeUsagePayloadForRequest(payload, body, targetFormat = nul
|
||||
/**
|
||||
* Normalize usage object - ensure all values are valid numbers
|
||||
*/
|
||||
export function normalizeUsage(usage) {
|
||||
export function normalizeUsage(usage: UsageLike | null | undefined) {
|
||||
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
|
||||
|
||||
const normalized: Record<string, number> = {};
|
||||
const assignNumber = (key, value) => {
|
||||
const assignNumber = (key: string, value: unknown) => {
|
||||
if (value === undefined || value === null) return;
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric)) normalized[key] = numeric;
|
||||
@@ -551,7 +619,7 @@ export function normalizeUsage(usage) {
|
||||
* Valid = has at least one token field with value > 0
|
||||
* Invalid = empty object {}, null, undefined, no token fields, or all zeros
|
||||
*/
|
||||
export function hasValidUsage(usage) {
|
||||
export function hasValidUsage(usage: UsageLike | null | undefined) {
|
||||
if (!usage || typeof usage !== "object") return false;
|
||||
|
||||
// Check for known token fields with value > 0
|
||||
@@ -577,7 +645,7 @@ export function hasValidUsage(usage) {
|
||||
/**
|
||||
* Extract usage from supported formats (Claude, OpenAI, Gemini, Responses API)
|
||||
*/
|
||||
export function extractUsage(chunk) {
|
||||
export function extractUsage(chunk: UsagePayloadLike | null | undefined) {
|
||||
if (!chunk || typeof chunk !== "object") return null;
|
||||
|
||||
// Claude/Antigravity streaming: message_start event carries INPUT tokens
|
||||
@@ -715,7 +783,7 @@ const CHARS_PER_TOKEN_SCHEMA = 6; // ~6 chars/token for JSON schemas (more verbo
|
||||
* @param {string} text - Text to estimate tokens for
|
||||
* @returns {number} Estimated token count
|
||||
*/
|
||||
function estimateTokenCount(text) {
|
||||
function estimateTokenCount(text: unknown) {
|
||||
if (!text || typeof text !== "string") return 0;
|
||||
|
||||
// Count CJK ideographs separately — each is roughly 1 token
|
||||
@@ -743,22 +811,23 @@ function estimateTokenCount(text) {
|
||||
* for more accurate estimation since JSON schemas are more verbose but
|
||||
* compress into fewer tokens than plain text.
|
||||
*/
|
||||
export function estimateInputTokens(body) {
|
||||
export function estimateInputTokens(body: unknown) {
|
||||
if (!body || typeof body !== "object") return 0;
|
||||
const record = body as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
let toolTokens = 0;
|
||||
let messageTokens = 0;
|
||||
|
||||
// Separate tool definitions from the rest of the body
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
const toolStr = JSON.stringify(body.tools);
|
||||
if (record.tools && Array.isArray(record.tools)) {
|
||||
const toolStr = JSON.stringify(record.tools);
|
||||
toolTokens = Math.ceil(toolStr.length / CHARS_PER_TOKEN_SCHEMA);
|
||||
// Estimate messages without tools
|
||||
const { tools, ...bodyWithoutTools } = body;
|
||||
const { tools, ...bodyWithoutTools } = record;
|
||||
messageTokens = estimateTokenCount(JSON.stringify(bodyWithoutTools));
|
||||
} else {
|
||||
messageTokens = estimateTokenCount(JSON.stringify(body));
|
||||
messageTokens = estimateTokenCount(JSON.stringify(record));
|
||||
}
|
||||
|
||||
return messageTokens + toolTokens;
|
||||
@@ -772,7 +841,7 @@ export function estimateInputTokens(body) {
|
||||
* Estimate output tokens from content length.
|
||||
* Uses improved heuristic when possible, falls back to length-based estimation.
|
||||
*/
|
||||
export function estimateOutputTokens(contentLength) {
|
||||
export function estimateOutputTokens(contentLength: number | null | undefined) {
|
||||
if (!contentLength || contentLength <= 0) return 0;
|
||||
// When we only have a character count, use 4 chars/token with sub-word correction
|
||||
return Math.max(1, Math.ceil(contentLength / 3.5));
|
||||
@@ -784,7 +853,7 @@ export function estimateOutputTokens(contentLength) {
|
||||
* @param {number} outputTokens - Output/completion tokens
|
||||
* @param {string} targetFormat - Target format from FORMATS
|
||||
*/
|
||||
export function formatUsage(inputTokens, outputTokens, targetFormat) {
|
||||
export function formatUsage(inputTokens: number, outputTokens: number, targetFormat: string) {
|
||||
// Claude format uses input_tokens/output_tokens
|
||||
if (targetFormat === FORMATS.CLAUDE) {
|
||||
return addBufferToUsage({
|
||||
@@ -809,7 +878,11 @@ export function formatUsage(inputTokens, outputTokens, targetFormat) {
|
||||
* @param {number} contentLength - Content length for output token estimation
|
||||
* @param {string} targetFormat - Target format from FORMATS constant
|
||||
*/
|
||||
export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI) {
|
||||
export function estimateUsage(
|
||||
body: unknown,
|
||||
contentLength: number | null | undefined,
|
||||
targetFormat: string = FORMATS.OPENAI
|
||||
) {
|
||||
return formatUsage(estimateInputTokens(body), estimateOutputTokens(contentLength), targetFormat);
|
||||
}
|
||||
|
||||
@@ -817,8 +890,8 @@ export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI
|
||||
* Log usage with cache info (green color)
|
||||
*/
|
||||
export function logUsage(
|
||||
provider,
|
||||
usage,
|
||||
provider: string | null | undefined,
|
||||
usage: UsageLike | null | undefined,
|
||||
model: string | null = null,
|
||||
connectionId: string | null = null,
|
||||
apiKeyInfo = null
|
||||
|
||||
@@ -3,9 +3,9 @@ import { z } from "zod";
|
||||
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { generateConfig } from "@/lib/cli-helper/config-generator";
|
||||
import { guardCliConfigWrite } from "@/lib/api/cliConfigWriteGuard";
|
||||
import { getCliPrimaryConfigPath, normalizeCliToolId } from "@/shared/services/cliRuntime";
|
||||
|
||||
const applySchema = z.object({
|
||||
toolId: z.string().min(1),
|
||||
@@ -15,21 +15,13 @@ const applySchema = z.object({
|
||||
dryRun: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const TOOL_CONFIG_PATHS: Record<string, string> = {
|
||||
claude: path.join(os.homedir(), ".claude", "settings.json"),
|
||||
codex: path.join(os.homedir(), ".codex", "config.yaml"),
|
||||
cline: path.join(os.homedir(), ".cline", "data", "globalState.json"),
|
||||
kilocode: path.join(os.homedir(), ".config", "kilocode", "settings.json"),
|
||||
continue: path.join(os.homedir(), ".continue", "config.yaml"),
|
||||
};
|
||||
|
||||
/** The host-side command that does the same job when OmniRoute is containerised. */
|
||||
const HOST_SETUP_COMMANDS: Record<string, string> = {
|
||||
claude: "omniroute setup-claude",
|
||||
codex: "omniroute setup-codex",
|
||||
opencode: "omniroute setup-opencode",
|
||||
cline: "omniroute setup-cline",
|
||||
kilocode: "omniroute setup-kilo",
|
||||
kilo: "omniroute setup-kilo",
|
||||
continue: "omniroute setup-continue",
|
||||
};
|
||||
|
||||
@@ -56,8 +48,9 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
const { toolId, baseUrl, apiKey, model, dryRun } = parsed.data;
|
||||
const canonicalToolId = normalizeCliToolId(toolId);
|
||||
|
||||
const result = await generateConfig(toolId, {
|
||||
const result = await generateConfig(canonicalToolId, {
|
||||
baseUrl: baseUrl || "http://localhost:20128/v1",
|
||||
apiKey,
|
||||
model,
|
||||
@@ -72,10 +65,11 @@ export async function POST(request: Request) {
|
||||
dryRun: true,
|
||||
configPath: result.configPath,
|
||||
content: result.content,
|
||||
...(result.migration ? { migration: result.migration } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const configPath = toolId === "opencode" ? result.configPath : TOOL_CONFIG_PATHS[toolId];
|
||||
const configPath = result.configPath || getCliPrimaryConfigPath(canonicalToolId);
|
||||
if (!configPath) {
|
||||
return NextResponse.json({ error: `Unknown tool: ${toolId}` }, { status: 400 });
|
||||
}
|
||||
@@ -83,8 +77,8 @@ export async function POST(request: Request) {
|
||||
// A container write into an unmounted path looks successful and then
|
||||
// disappears with the container — refuse it and point at the host CLI.
|
||||
const refusal = guardCliConfigWrite(configPath, {
|
||||
toolLabel: toolId,
|
||||
hostCommand: HOST_SETUP_COMMANDS[toolId],
|
||||
toolLabel: canonicalToolId,
|
||||
hostCommand: HOST_SETUP_COMMANDS[canonicalToolId],
|
||||
});
|
||||
if (refusal) return refusal;
|
||||
|
||||
@@ -100,6 +94,7 @@ export async function POST(request: Request) {
|
||||
configPath,
|
||||
backupPath,
|
||||
content: result.content,
|
||||
...(result.migration ? { migration: result.migration } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error applying config:", error);
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
* Re-exports the registry and manager for convenient imports.
|
||||
*/
|
||||
|
||||
export { detectInstalledAgents, getAgentById, getAvailableAgents } from "./registry";
|
||||
export {
|
||||
detectInstalledAgents,
|
||||
getAgentById,
|
||||
getAvailableAgents,
|
||||
hasRegisteredAgent,
|
||||
} from "./registry";
|
||||
export type { CliAgentInfo } from "./registry";
|
||||
|
||||
export { AcpManager, acpManager } from "./manager";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
import { hasRegisteredAgent } from "./registry";
|
||||
|
||||
export interface AcpSession {
|
||||
/** Unique session ID */
|
||||
@@ -47,11 +48,18 @@ export class AcpManager extends EventEmitter {
|
||||
args: string[] = [],
|
||||
env: Record<string, string> = {}
|
||||
): AcpSession {
|
||||
const ALLOWED_AGENTS = ["claude", "codex", "gemini", "qwen"];
|
||||
if (!ALLOWED_AGENTS.includes(agentId)) {
|
||||
const normalizedAgentId = String(agentId || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!hasRegisteredAgent(normalizedAgentId)) {
|
||||
throw new Error(`Unknown agent: ${agentId}`);
|
||||
}
|
||||
|
||||
// Keep session ids and telemetry stable when a caller uses a registry
|
||||
// alias/custom spelling. The registry remains the source of truth for
|
||||
// which ACP-capable IDs may be spawned.
|
||||
agentId = normalizedAgentId;
|
||||
|
||||
const sessionId = `acp-${agentId}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
const child = spawn(binary, args, {
|
||||
|
||||
@@ -69,6 +69,15 @@ const AGENT_DEFINITIONS: Omit<CliAgentInfo, "version" | "installed">[] = [
|
||||
spawnArgs: ["--print", "--output-format", "json"],
|
||||
protocol: "stdio",
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
name: "Google Gemini CLI",
|
||||
binary: "gemini",
|
||||
versionCommand: "gemini --version",
|
||||
providerAlias: "gemini",
|
||||
spawnArgs: [],
|
||||
protocol: "stdio",
|
||||
},
|
||||
{
|
||||
id: "goose",
|
||||
name: "Goose CLI",
|
||||
@@ -385,6 +394,24 @@ export function getAgentById(id: string): CliAgentInfo | undefined {
|
||||
return agents.find((a) => a.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check registration without probing every executable on PATH.
|
||||
*
|
||||
* Process lifecycle callers need an allowlist decision, not a fresh health
|
||||
* scan. Keeping this lookup pure avoids making `spawn()` wait on one timeout
|
||||
* per uninstalled agent while preserving detectInstalledAgents() for UI/status
|
||||
* consumers.
|
||||
*/
|
||||
export function hasRegisteredAgent(id: string): boolean {
|
||||
const normalized = String(id || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return (
|
||||
AGENT_DEFINITIONS.some((agent) => agent.id === normalized) ||
|
||||
_customAgentDefs.some((agent) => agent.id === normalized)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agents that are installed and available for ACP.
|
||||
*/
|
||||
|
||||
@@ -1,34 +1,93 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
import { parse, stringify } from "smol-toml";
|
||||
|
||||
let yaml: typeof import("js-yaml") | null = null;
|
||||
async function loadYaml() {
|
||||
if (!yaml) {
|
||||
yaml = await import("js-yaml");
|
||||
}
|
||||
return yaml;
|
||||
/**
|
||||
* Codex CLI config generator — TOML.
|
||||
*
|
||||
* Modern Codex (Rust CLI, v0.137+) reads `~/.codex/config.toml` exclusively;
|
||||
* the YAML `~/.codex/config.yaml` this generator used to emit belongs to the
|
||||
* legacy npm codex-cli and is silently ignored by current binaries. The shape
|
||||
* below matches the documented OmniRoute block
|
||||
* (docs/guides/CODEX-CLI-CONFIGURATION.md → "Ready-to-paste config.toml").
|
||||
*
|
||||
* Two deliberate safety properties:
|
||||
* - The API key is NEVER written into the file. Codex reads it from the env
|
||||
* var named by `env_key` (`OMNIROUTE_API_KEY`), so the generated content is
|
||||
* credential-free and safe to show in dry-run.
|
||||
* - An existing `config.toml` is merged conservatively: every unrelated key
|
||||
* the operator already has is preserved; only `model`, `model_provider` and
|
||||
* `[model_providers.omniroute]` are set. An existing file that fails TOML
|
||||
* parsing aborts generation instead of clobbering the operator's config.
|
||||
*/
|
||||
|
||||
export const CODEX_MODEL_PROVIDER_ID = "omniroute";
|
||||
|
||||
export function getCodexHome(): string {
|
||||
return path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.yaml");
|
||||
/** Path of the legacy YAML config, when one is left over from old generators. */
|
||||
export function findLegacyCodexYaml(codexHome: string = getCodexHome()): string | null {
|
||||
const legacyPath = path.join(codexHome, "config.yaml");
|
||||
try {
|
||||
return fs.existsSync(legacyPath) ? legacyPath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateCodexConfig(options: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
/** Override for tests; production callers use ~/.codex/config.toml. */
|
||||
configPath?: string;
|
||||
}): Promise<string> {
|
||||
const y = await loadYaml();
|
||||
let base = options.baseUrl;
|
||||
let end = base.length;
|
||||
while (end > 0 && base[end - 1] === "/") end--;
|
||||
base = end < base.length ? base.slice(0, end) : base;
|
||||
if (base.endsWith("/v1")) base = base.slice(0, -3);
|
||||
|
||||
const config = {
|
||||
openai: {
|
||||
api_key: options.apiKey,
|
||||
base_url: `${base}/v1`,
|
||||
const configPath = options.configPath ?? path.join(getCodexHome(), "config.toml");
|
||||
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
const raw = fs.readFileSync(configPath, "utf-8");
|
||||
try {
|
||||
existing = parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Existing ${configPath} is not valid TOML; refusing to overwrite it. ` +
|
||||
"Fix or move the file, then retry."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingProviders =
|
||||
existing.model_providers && typeof existing.model_providers === "object"
|
||||
? (existing.model_providers as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const merged: Record<string, unknown> = {
|
||||
...existing,
|
||||
...(options.model ? { model: options.model } : {}),
|
||||
model_provider: CODEX_MODEL_PROVIDER_ID,
|
||||
model_providers: {
|
||||
...existingProviders,
|
||||
[CODEX_MODEL_PROVIDER_ID]: {
|
||||
name: "OmniRoute",
|
||||
base_url: `${base}/v1`,
|
||||
env_key: "OMNIROUTE_API_KEY",
|
||||
requires_openai_auth: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return y.dump(config, { lineWidth: -1 });
|
||||
const header =
|
||||
"# Generated by OmniRoute. The API key is read from the OMNIROUTE_API_KEY\n" +
|
||||
"# environment variable (env_key) and is never stored in this file.\n";
|
||||
return header + stringify(merged) + "\n";
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import os from "node:os";
|
||||
import { getHermesConfigPath } from "./hermesHome.ts";
|
||||
import { generateClaudeConfig } from "./claude";
|
||||
import { generateClineConfig } from "./cline";
|
||||
import { generateCodexConfig } from "./codex";
|
||||
import { generateCodexConfig, findLegacyCodexYaml } from "./codex";
|
||||
import { generateContinueConfig } from "./continue";
|
||||
import { generateHermesConfig } from "./hermes";
|
||||
import { generateHermesAgentConfig, type HermesAgentConfigPayload } from "./hermes-agent";
|
||||
import { generateKilocodeConfig } from "./kilocode";
|
||||
import { generateOpencodeConfig } from "./opencode";
|
||||
import { resolveOpencodeConfigPath } from "../../../shared/services/opencodeConfigPath";
|
||||
import { normalizeCliToolId } from "../../../shared/services/cliRuntime";
|
||||
|
||||
export interface GenerateOptions {
|
||||
baseUrl: string;
|
||||
@@ -23,6 +24,8 @@ export interface GenerateResult {
|
||||
configPath: string;
|
||||
content?: string;
|
||||
error?: string;
|
||||
/** Human-readable migration note (e.g. a legacy config file that is now ignored). */
|
||||
migration?: string;
|
||||
}
|
||||
|
||||
export function validateBaseUrl(url: string): boolean {
|
||||
@@ -42,9 +45,12 @@ function expandHome(p: string): string {
|
||||
// Static paths that do not depend on runtime env vars can stay eagerly computed.
|
||||
const STATIC_TOOL_CONFIG_PATHS: Record<string, string> = {
|
||||
claude: path.join(os.homedir(), ".claude", "settings.json"),
|
||||
codex: path.join(os.homedir(), ".codex", "config.yaml"),
|
||||
// Modern Codex (v0.137+) reads TOML only; config.yaml is the legacy npm CLI.
|
||||
codex: path.join(os.homedir(), ".codex", "config.toml"),
|
||||
cline: path.join(os.homedir(), ".cline", "data", "globalState.json"),
|
||||
kilocode: path.join(os.homedir(), ".config", "kilocode", "settings.json"),
|
||||
// `kilo` is the canonical id; the file name remains `kilocode` because the
|
||||
// VS Code extension owns that settings namespace.
|
||||
kilo: path.join(os.homedir(), ".config", "kilocode", "settings.json"),
|
||||
continue: path.join(os.homedir(), ".continue", "config.yaml"),
|
||||
};
|
||||
|
||||
@@ -55,6 +61,7 @@ const STATIC_TOOL_CONFIG_PATHS: Record<string, string> = {
|
||||
* honoured (#3628). All other tools use the eagerly-computed static map.
|
||||
*/
|
||||
function getToolConfigPath(toolId: string): string {
|
||||
toolId = normalizeCliToolId(toolId);
|
||||
if (toolId === "hermes" || toolId === "hermes-agent") {
|
||||
return getHermesConfigPath();
|
||||
}
|
||||
@@ -71,7 +78,7 @@ const GENERATORS: Record<string, ConfigGenerator> = {
|
||||
codex: generateCodexConfig,
|
||||
opencode: generateOpencodeConfig,
|
||||
cline: generateClineConfig,
|
||||
kilocode: generateKilocodeConfig,
|
||||
kilo: generateKilocodeConfig,
|
||||
continue: generateContinueConfig,
|
||||
hermes: generateHermesConfig,
|
||||
"hermes-agent": generateHermesAgentConfig as any, // rich multi-role version
|
||||
@@ -94,16 +101,31 @@ export async function generateConfig(
|
||||
}
|
||||
|
||||
try {
|
||||
const generate = GENERATORS[toolId];
|
||||
const canonicalToolId = normalizeCliToolId(toolId);
|
||||
const generate = GENERATORS[canonicalToolId];
|
||||
if (!generate) {
|
||||
return { success: false, configPath: "", error: `Unknown tool: ${toolId}` };
|
||||
}
|
||||
const configPath = getToolConfigPath(toolId);
|
||||
const configPath = getToolConfigPath(canonicalToolId);
|
||||
const content =
|
||||
toolId === "opencode"
|
||||
canonicalToolId === "opencode"
|
||||
? await generateOpencodeConfig({ ...options, configPath })
|
||||
: await generate(options);
|
||||
return { success: true, configPath, content };
|
||||
: canonicalToolId === "codex"
|
||||
? await generateCodexConfig({ ...options, configPath })
|
||||
: await generate(options);
|
||||
|
||||
let migration: string | undefined;
|
||||
if (canonicalToolId === "codex") {
|
||||
const legacyYaml = findLegacyCodexYaml();
|
||||
if (legacyYaml) {
|
||||
migration =
|
||||
`Legacy ${legacyYaml} found — modern Codex (v0.137+) ignores YAML and reads ` +
|
||||
`only config.toml. The YAML file was left untouched; remove it manually once ` +
|
||||
`you confirm nothing else uses it.`;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, configPath, content, ...(migration ? { migration } : {}) };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, configPath: "", error: `Generation failed: ${msg}` };
|
||||
@@ -111,15 +133,10 @@ export async function generateConfig(
|
||||
}
|
||||
|
||||
export async function generateAllConfigs(options: GenerateOptions): Promise<GenerateResult[]> {
|
||||
const toolIds = [
|
||||
"claude",
|
||||
"codex",
|
||||
"opencode",
|
||||
"cline",
|
||||
"kilocode",
|
||||
"continue",
|
||||
"hermes",
|
||||
] as const;
|
||||
// Keep the batch view derived from the actual generator registry. Hermes
|
||||
// Agent has a richer payload and is intentionally exposed by its dedicated
|
||||
// endpoint, not by this simple `{baseUrl, apiKey, model}` batch API.
|
||||
const toolIds = Object.keys(GENERATORS).filter((id) => id !== "hermes-agent");
|
||||
const results = await Promise.allSettled(toolIds.map((id) => generateConfig(id, options)));
|
||||
|
||||
return results.map((r) =>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { getCurrentHermesAgentRoles } from "./config-generator/hermes-agent";
|
||||
import { getHermesConfigPath } from "./config-generator/hermesHome";
|
||||
import { getCliTool, listCliTools } from "../../shared/constants/cliTools";
|
||||
import {
|
||||
CLI_TOOL_IDS,
|
||||
getLookupEnv,
|
||||
getCliPrimaryConfigPath,
|
||||
getCliToolCommandCandidates,
|
||||
locateCommand,
|
||||
normalizeCliToolId,
|
||||
shouldUseShellForCommand,
|
||||
} from "../../shared/services/cliRuntime";
|
||||
import { resolveOpencodeConfigPath } from "../../shared/services/opencodeConfigPath";
|
||||
@@ -42,30 +47,29 @@ export interface DetectedTool {
|
||||
>;
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
{ id: "claude", name: "Claude Code", configPath: "~/.claude/settings.json" },
|
||||
{ id: "codex", name: "Codex CLI", configPath: "~/.codex/config.yaml" },
|
||||
{ id: "opencode", name: "OpenCode", configPath: resolveOpencodeConfigPath },
|
||||
{ id: "cline", name: "Cline", configPath: "~/.cline/data/globalState.json" },
|
||||
{ id: "kilocode", name: "Kilo Code", configPath: "~/.config/kilocode/settings.json" },
|
||||
{ id: "continue", name: "Continue", configPath: "~/.continue/config.yaml" },
|
||||
{ id: "hermes", name: "Hermes", configPath: "~/.hermes/config.yaml" },
|
||||
{ id: "hermes-agent", name: "Hermes Agent", configPath: "~/.hermes/config.yaml" },
|
||||
{ id: "openclaw", name: "OpenClaw", configPath: "~/.openclaw/openclaw.json" },
|
||||
] as const;
|
||||
type ToolDescriptor = { id: string; name: string; configPath: string };
|
||||
|
||||
const BINARY_NAMES: Record<string, string> = {
|
||||
claude: "claude",
|
||||
codex: "codex",
|
||||
opencode: "opencode",
|
||||
cline: "cline",
|
||||
kilocode: "kilocode",
|
||||
continue: "continue",
|
||||
hermes: "hermes",
|
||||
"hermes-agent": "hermes",
|
||||
openclaw: "openclaw",
|
||||
// Keep the long-standing CLI status labels stable while the UI catalog uses
|
||||
// marketing names (for example, "Open Claw").
|
||||
const DETECTOR_NAME_OVERRIDES: Readonly<Record<string, string>> = {
|
||||
claude: "Claude Code",
|
||||
codex: "Codex CLI",
|
||||
openclaw: "OpenClaw",
|
||||
};
|
||||
|
||||
/**
|
||||
* The detector is a read-only view over the shared runtime/UI catalogs.
|
||||
* Runtime-only entries (for example qoder) are retained, while guide-only UI
|
||||
* entries still appear with an empty config path and `installed: false`.
|
||||
*/
|
||||
const TOOLS: ToolDescriptor[] = Array.from(
|
||||
new Set([...listCliTools().map((tool) => tool.id), ...CLI_TOOL_IDS])
|
||||
).map((id) => ({
|
||||
id,
|
||||
name: DETECTOR_NAME_OVERRIDES[id] || getCliTool(id)?.name || id,
|
||||
configPath: "",
|
||||
}));
|
||||
|
||||
function expandHome(p: string): string {
|
||||
const home = os.homedir();
|
||||
return p.replace(/^~\//, home + "/");
|
||||
@@ -111,27 +115,35 @@ async function detectBinaryWindows(
|
||||
}
|
||||
|
||||
async function detectBinary(name: string): Promise<{ installed: boolean; version?: string }> {
|
||||
const binary = BINARY_NAMES[name] || name;
|
||||
const binaries = getCliToolCommandCandidates(name);
|
||||
if (binaries.length === 0) return { installed: false };
|
||||
const env = getLookupEnv();
|
||||
|
||||
if (process.platform === "win32") {
|
||||
return detectBinaryWindows(binary, env);
|
||||
for (const binary of binaries) {
|
||||
if (process.platform === "win32") {
|
||||
const result = await detectBinaryWindows(binary, env);
|
||||
if (result.installed) return result;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileImpl(binary, ["--version"], { timeout: 5000, env });
|
||||
const version = stdout.trim().replace(/^v/, "");
|
||||
return { installed: true, version };
|
||||
} catch {
|
||||
try {
|
||||
// Try `which` as fallback (routed through execFileImpl so it stays mockable)
|
||||
const { stdout } = await execFileImpl("which", [binary], { timeout: 5000, env });
|
||||
if (stdout.trim()) {
|
||||
return { installed: true };
|
||||
}
|
||||
} catch {
|
||||
// Try the next declared command candidate.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileImpl(binary, ["--version"], { timeout: 5000, env });
|
||||
const version = stdout.trim().replace(/^v/, "");
|
||||
return { installed: true, version };
|
||||
} catch {
|
||||
try {
|
||||
// Try `which` as fallback (routed through execFileImpl so it stays mockable)
|
||||
const { stdout } = await execFileImpl("which", [binary], { timeout: 5000, env });
|
||||
if (stdout.trim()) {
|
||||
return { installed: true };
|
||||
}
|
||||
} catch {}
|
||||
return { installed: false };
|
||||
}
|
||||
return { installed: false };
|
||||
}
|
||||
|
||||
async function readConfigFile(configPath: string): Promise<string | null> {
|
||||
@@ -146,17 +158,21 @@ async function readConfigFile(configPath: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
export async function detectTool(id: string): Promise<DetectedTool | null> {
|
||||
const tool = TOOLS.find((t) => t.id === id);
|
||||
const canonicalId = normalizeCliToolId(id);
|
||||
const tool = TOOLS.find((t) => t.id === canonicalId);
|
||||
if (!tool) return null;
|
||||
|
||||
const { installed, version } = await detectBinary(tool.id);
|
||||
const configPath =
|
||||
typeof tool.configPath === "function" ? tool.configPath() : expandHome(tool.configPath);
|
||||
tool.id === "hermes" || tool.id === "hermes-agent"
|
||||
? getHermesConfigPath()
|
||||
: getCliPrimaryConfigPath(tool.id) ||
|
||||
(tool.id === "opencode" ? resolveOpencodeConfigPath() : "");
|
||||
const configContents = await readConfigFile(configPath);
|
||||
const configured = !!configContents && isConfigured(configContents, "http://localhost:20128");
|
||||
|
||||
const result: DetectedTool = {
|
||||
id: tool.id,
|
||||
id: canonicalId,
|
||||
name: tool.name,
|
||||
installed,
|
||||
version,
|
||||
|
||||
@@ -28,7 +28,7 @@ const PASSTHROUGH_PROVIDERS = new Set(
|
||||
);
|
||||
|
||||
// Wrap isValidModel with passthrough providers
|
||||
export function isValidModel(aliasOrId, modelId) {
|
||||
export function isValidModel(aliasOrId: string, modelId: string) {
|
||||
if (isOpenAICompatibleProvider(aliasOrId)) return true;
|
||||
if (isAnthropicCompatibleProvider(aliasOrId)) return true;
|
||||
if (PASSTHROUGH_PROVIDERS.has(aliasOrId)) return true;
|
||||
|
||||
@@ -198,6 +198,34 @@ const CLI_TOOLS: Record<string, any> = {
|
||||
env: ".qwen/.env",
|
||||
},
|
||||
},
|
||||
aider: {
|
||||
defaultCommand: "aider",
|
||||
envBinKey: "CLI_AIDER_BIN",
|
||||
requiresBinary: true,
|
||||
healthcheckTimeoutMs: 12000,
|
||||
paths: {
|
||||
config: ".aider.conf.yml",
|
||||
},
|
||||
},
|
||||
goose: {
|
||||
defaultCommand: "goose",
|
||||
envBinKey: "CLI_GOOSE_BIN",
|
||||
requiresBinary: true,
|
||||
healthcheckTimeoutMs: 12000,
|
||||
paths: {
|
||||
config: ".config/goose/config.yaml",
|
||||
},
|
||||
},
|
||||
gemini: {
|
||||
defaultCommand: "gemini",
|
||||
envBinKey: "CLI_GEMINI_BIN",
|
||||
requiresBinary: true,
|
||||
// gemini-cli cold start (bundle + extension discovery) can exceed 4s.
|
||||
healthcheckTimeoutMs: 15000,
|
||||
paths: {
|
||||
settings: ".gemini/settings.json",
|
||||
},
|
||||
},
|
||||
// ── Plan 14 — new "custom" configType tools ───────────────────────────────
|
||||
forge: {
|
||||
defaultCommand: "forge",
|
||||
@@ -286,6 +314,33 @@ const CLI_TOOLS: Record<string, any> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Compatibility aliases accepted by CLI/API callers.
|
||||
*
|
||||
* The runtime catalog keeps one canonical id per executable. Older surfaces
|
||||
* exposed a binary name (notably `kilocode`) or launcher aliases instead of
|
||||
* that id, so normalize them at the boundary rather than duplicating entries.
|
||||
*/
|
||||
export const CLI_TOOL_ALIASES: Readonly<Record<string, string>> = {
|
||||
kilocode: "kilo",
|
||||
"kilo-code": "kilo",
|
||||
kilo_cli: "kilo",
|
||||
cc: "claude",
|
||||
"claude-code": "claude",
|
||||
"openai-codex": "codex",
|
||||
openai: "codex",
|
||||
cn: "continue",
|
||||
qodercli: "qoder",
|
||||
};
|
||||
|
||||
/** Resolve a user-facing or legacy id to the canonical runtime id. */
|
||||
export const normalizeCliToolId = (toolId: string): string => {
|
||||
const normalized = String(toolId || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return CLI_TOOL_ALIASES[normalized] || normalized;
|
||||
};
|
||||
|
||||
const isWindows = () => process.platform === "win32";
|
||||
|
||||
/**
|
||||
@@ -568,6 +623,7 @@ const getExtraPaths = () =>
|
||||
* Works on all platforms — Windows checks .cmd wrappers, Linux/macOS checks bare names.
|
||||
*/
|
||||
export const getKnownToolPaths = (toolId: string): string[] => {
|
||||
toolId = normalizeCliToolId(toolId);
|
||||
const home = os.homedir();
|
||||
const paths: string[] = [];
|
||||
|
||||
@@ -730,7 +786,7 @@ export const getLookupEnv = () => {
|
||||
};
|
||||
|
||||
const resolveToolCommands = (toolId: string): string[] => {
|
||||
const tool = CLI_TOOLS[toolId];
|
||||
const tool = CLI_TOOLS[normalizeCliToolId(toolId)];
|
||||
if (!tool) return [];
|
||||
const envCommand = String(process.env[tool.envBinKey] || "").trim();
|
||||
if (envCommand) return [envCommand];
|
||||
@@ -740,6 +796,16 @@ const resolveToolCommands = (toolId: string): string[] => {
|
||||
return tool.defaultCommand ? [tool.defaultCommand] : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Return command candidates without probing the filesystem.
|
||||
*
|
||||
* Lightweight consumers (config status and CLI inventory) use this to build
|
||||
* a version probe while getCliRuntimeStatus() remains the authoritative
|
||||
* health/runnability check.
|
||||
*/
|
||||
export const getCliToolCommandCandidates = (toolId: string): string[] =>
|
||||
resolveToolCommands(toolId);
|
||||
|
||||
const checkExplicitPath = async (commandPath: string) => {
|
||||
// Reject paths that look like injection attempts
|
||||
if (!isSafePath(commandPath)) {
|
||||
@@ -781,13 +847,13 @@ export const locateCommand = async (command: string, env: Record<string, string
|
||||
// and a .cmd wrapper. We must prefer the Windows executable extension.
|
||||
const lines = located.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.map((l: string) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
return { installed: false, commandPath: null, reason: "not_found" };
|
||||
}
|
||||
const winExt = /\.(cmd|exe|bat|com)$/i;
|
||||
const preferred = lines.find((l) => winExt.test(l)) || lines[0];
|
||||
const preferred = lines.find((l: string) => winExt.test(l)) || lines[0];
|
||||
return { installed: true, commandPath: normalizeMsys2Path(preferred), reason: null };
|
||||
}
|
||||
return { installed: false, commandPath: null, reason: "not_found" };
|
||||
@@ -1025,6 +1091,7 @@ export const resolveOpencodeConfigPath = (
|
||||
export const getOpenCodeConfigPath = () => resolveOpencodeConfigPath();
|
||||
|
||||
export const getCliConfigPaths = (toolId: string) => {
|
||||
toolId = normalizeCliToolId(toolId);
|
||||
const tool = CLI_TOOLS[toolId];
|
||||
if (!tool) return null;
|
||||
|
||||
@@ -1071,6 +1138,7 @@ export const getCliPrimaryConfigPath = (toolId: string) => {
|
||||
};
|
||||
|
||||
export const getCliRuntimeStatus = async (toolId: string) => {
|
||||
toolId = normalizeCliToolId(toolId);
|
||||
const tool = CLI_TOOLS[toolId];
|
||||
const runtimeMode = getRuntimeMode();
|
||||
if (!tool) {
|
||||
|
||||
178
tests/integration/upstream-cli-smoke.int.test.ts
Normal file
178
tests/integration/upstream-cli-smoke.int.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Opt-in REAL smoke harness for upstream CLIs launched through `omniroute run`.
|
||||
*
|
||||
* Deterministic regression for the launch plans lives in
|
||||
* `tests/unit/cli/run-command.test.ts` (dry-run plans) and
|
||||
* `tests/unit/cli/run-execution.test.ts` (child-process isolation). This file
|
||||
* exercises the REAL binaries against a REAL OmniRoute server and therefore:
|
||||
*
|
||||
* - NEVER runs automatically: every sub-test skips unless RUN_CLI_SMOKE=1;
|
||||
* - NEVER ships or prints credentials: the API key is passed by env-var NAME
|
||||
* (`--api-key-env`), values are never logged, and assertions only inspect
|
||||
* exit codes and redacted output classes;
|
||||
* - classifies failures as binary-missing / server-unreachable / auth /
|
||||
* upstream instead of a bare boolean.
|
||||
*
|
||||
* Operator usage (all knobs are env vars — no secrets on the command line):
|
||||
*
|
||||
* RUN_CLI_SMOKE=1 \
|
||||
* OMNIROUTE_SMOKE_BASE_URL="http://localhost:20128" \
|
||||
* OMNIROUTE_SMOKE_MODEL="<provider/model>" \
|
||||
* OMNIROUTE_SMOKE_API_KEY_ENV="OMNIROUTE_API_KEY" \
|
||||
* node --import tsx/esm --test tests/integration/upstream-cli-smoke.int.test.ts
|
||||
*
|
||||
* Optional: OMNIROUTE_SMOKE_TARGETS="codex,opencode,qwen" restricts the sweep;
|
||||
* OMNIROUTE_SMOKE_TIMEOUT_MS overrides the per-target timeout (default 120s).
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const ENABLED = process.env.RUN_CLI_SMOKE === "1";
|
||||
const BASE_URL = (process.env.OMNIROUTE_SMOKE_BASE_URL || "http://localhost:20128").replace(
|
||||
/\/+$/,
|
||||
""
|
||||
);
|
||||
const MODEL = process.env.OMNIROUTE_SMOKE_MODEL || "";
|
||||
const API_KEY_ENV = process.env.OMNIROUTE_SMOKE_API_KEY_ENV || "OMNIROUTE_API_KEY";
|
||||
const TIMEOUT_MS = Number(process.env.OMNIROUTE_SMOKE_TIMEOUT_MS || 120_000);
|
||||
|
||||
const CLI_ENTRY = fileURLToPath(new URL("../../bin/omniroute.mjs", import.meta.url));
|
||||
|
||||
/** One-shot, non-interactive invocation per target. Prompts are inert. */
|
||||
const SMOKE_TARGETS: Record<string, { args: string[] }> = {
|
||||
codex: { args: ["exec", "--skip-git-repo-check", "reply with the single word OK"] },
|
||||
aider: { args: ["--message", "reply with the single word OK", "--no-git", "--yes-always"] },
|
||||
goose: { args: ["run", "-t", "reply with the single word OK"] },
|
||||
opencode: { args: ["run", "reply with the single word OK"] },
|
||||
qwen: { args: ["-p", "reply with the single word OK"] },
|
||||
gemini: { args: ["--skip-trust", "-p", "reply with the single word OK"] },
|
||||
};
|
||||
|
||||
function selectedTargets(): string[] {
|
||||
const filter = String(process.env.OMNIROUTE_SMOKE_TARGETS || "")
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const all = Object.keys(SMOKE_TARGETS);
|
||||
return filter.length ? all.filter((t) => filter.includes(t)) : all;
|
||||
}
|
||||
|
||||
function binaryAvailable(target: string): boolean {
|
||||
try {
|
||||
execFileSync("sh", ["-c", 'command -v -- "$1"', "sh", target], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 5000,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function serverReachable(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Redact anything that looks like a secret before recording output. */
|
||||
function redact(text: string): string {
|
||||
return text
|
||||
.replace(/(sk|pk|rk)[-_][A-Za-z0-9_-]{8,}/g, "[redacted-key]")
|
||||
.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [redacted]")
|
||||
.slice(0, 2000);
|
||||
}
|
||||
|
||||
type SmokeResult = {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
classification: "pass" | "auth" | "upstream" | "config" | "unknown";
|
||||
};
|
||||
|
||||
function classify(exitCode: number | null, output: string): SmokeResult["classification"] {
|
||||
if (exitCode === 0) return "pass";
|
||||
if (/401|403|unauthorized|invalid[_ ]api[_ ]key/i.test(output)) return "auth";
|
||||
if (/5\d\d|upstream|overloaded|rate.?limit|429/i.test(output)) return "upstream";
|
||||
if (/not found|unknown model|unsupported|invalid (option|argument)/i.test(output)) {
|
||||
return "config";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function runSmoke(target: string): Promise<SmokeResult> {
|
||||
const spec = SMOKE_TARGETS[target];
|
||||
const args = [
|
||||
CLI_ENTRY,
|
||||
"run",
|
||||
target,
|
||||
"--base-url",
|
||||
BASE_URL,
|
||||
"--api-key-env",
|
||||
API_KEY_ENV,
|
||||
...(MODEL ? ["--model", MODEL] : []),
|
||||
"--",
|
||||
...spec.args,
|
||||
];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, args, {
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (c) => (stdout += String(c)));
|
||||
child.stderr.on("data", (c) => (stderr += String(c)));
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
const combined = redact(stdout + "\n" + stderr);
|
||||
resolve({
|
||||
exitCode: code,
|
||||
stdout: redact(stdout),
|
||||
stderr: redact(stderr),
|
||||
classification: classify(code, combined),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("upstream CLI smoke sweep (opt-in via RUN_CLI_SMOKE=1)", { timeout: 0 }, async (t) => {
|
||||
if (!ENABLED) {
|
||||
t.skip("RUN_CLI_SMOKE!=1 — real smoke is operator opt-in, never automatic");
|
||||
return;
|
||||
}
|
||||
assert.ok(MODEL, "OMNIROUTE_SMOKE_MODEL must name the provider/model to exercise");
|
||||
assert.ok(
|
||||
process.env[API_KEY_ENV] !== undefined,
|
||||
`credential env var '${API_KEY_ENV}' must exist (value is never printed)`
|
||||
);
|
||||
assert.ok(await serverReachable(), `OmniRoute is not reachable at ${BASE_URL}`);
|
||||
|
||||
for (const target of selectedTargets()) {
|
||||
await t.test(`smoke: ${target}`, async (st) => {
|
||||
if (!binaryAvailable(target)) {
|
||||
st.skip(`binary '${target}' not installed on this machine`);
|
||||
return;
|
||||
}
|
||||
const result = await runSmoke(target);
|
||||
st.diagnostic(`${target}: exit=${result.exitCode} class=${result.classification}`);
|
||||
assert.equal(
|
||||
result.classification,
|
||||
"pass",
|
||||
`${target} smoke failed (exit=${result.exitCode}, class=${result.classification}).\n` +
|
||||
`stderr (redacted): ${result.stderr.slice(0, 500)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
156
tests/unit/api/cli-tools/apply-container-guard.test.ts
Normal file
156
tests/unit/api/cli-tools/apply-container-guard.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Container-guard homologation for POST /api/cli-tools/apply.
|
||||
*
|
||||
* Both runtime modes are exercised by SCOPED `OMNIROUTE_CONTAINER` overrides
|
||||
* (set per test, restored in finally). The override is the documented test
|
||||
* seam of `isRunningInContainer()`; it is never forced globally — forcing it
|
||||
* off for the whole suite would hide a regression in the guard itself.
|
||||
*/
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-apply-guard-data-"));
|
||||
const TEST_XDG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-apply-guard-xdg-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
const originalXdg = process.env.XDG_CONFIG_HOME;
|
||||
// Fresh DB without a configured password → management auth is open, so these
|
||||
// tests exercise the guard, not the auth stack (covered elsewhere).
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.XDG_CONFIG_HOME = TEST_XDG_DIR;
|
||||
|
||||
const core = await import("../../../../src/lib/db/core.ts");
|
||||
const { POST } = await import("../../../../src/app/api/cli-tools/apply/route.ts");
|
||||
|
||||
const OPENCODE_CONFIG = path.join(TEST_XDG_DIR, "opencode", "opencode.json");
|
||||
|
||||
// The OpenCode generator refuses to write without the live /v1/models catalog
|
||||
// (context windows are catalog-sourced by design), so serve a minimal catalog
|
||||
// from an in-test loopback server instead of mocking generator internals.
|
||||
let catalogServer: http.Server;
|
||||
let catalogBaseUrl = "";
|
||||
|
||||
function startCatalogServer(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
catalogServer = http.createServer((req, res) => {
|
||||
if (String(req.url).startsWith("/v1/models")) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
data: [{ id: "glm/glm-5.2", object: "model", context_length: 128000 }],
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
});
|
||||
catalogServer.listen(0, "127.0.0.1", () => {
|
||||
const address = catalogServer.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
resolve(`http://127.0.0.1:${port}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function applyRequest(body: Record<string, unknown>): Request {
|
||||
return new Request("http://localhost:3000/api/cli-tools/apply", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function withContainerMode<T>(mode: "1" | "0", run: () => Promise<T>): Promise<T> {
|
||||
const original = process.env.OMNIROUTE_CONTAINER;
|
||||
const originalAllow = process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE;
|
||||
process.env.OMNIROUTE_CONTAINER = mode;
|
||||
delete process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.OMNIROUTE_CONTAINER;
|
||||
else process.env.OMNIROUTE_CONTAINER = original;
|
||||
if (originalAllow !== undefined) {
|
||||
process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = originalAllow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("POST /api/cli-tools/apply — container guard", () => {
|
||||
before(async () => {
|
||||
catalogBaseUrl = await startCatalogServer();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
catalogServer?.close();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(TEST_XDG_DIR, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
||||
else process.env.XDG_CONFIG_HOME = originalXdg;
|
||||
});
|
||||
|
||||
it("refuses an OpenCode write in container mode with a safe 422", async () => {
|
||||
const res = await withContainerMode("1", () =>
|
||||
POST(
|
||||
applyRequest({
|
||||
toolId: "opencode",
|
||||
baseUrl: catalogBaseUrl,
|
||||
apiKey: "sk-test-guard",
|
||||
})
|
||||
)
|
||||
);
|
||||
assert.strictEqual(res.status, 422);
|
||||
const body = await res.json();
|
||||
assert.ok(body.containerEphemeralTarget, "422 must be keyed as containerEphemeralTarget");
|
||||
assert.strictEqual(body.hostSetupCommand, "omniroute setup-opencode");
|
||||
assert.ok(typeof body.error === "string" && body.error.length > 0);
|
||||
assert.ok(!body.error.includes("at /"), "error must not leak a stack trace");
|
||||
assert.ok(!body.error.includes("sk-test-guard"), "error must not leak the API key");
|
||||
assert.strictEqual(fs.existsSync(OPENCODE_CONFIG), false, "nothing may be written");
|
||||
});
|
||||
|
||||
it("still serves dry-run previews in container mode without writing", async () => {
|
||||
const res = await withContainerMode("1", () =>
|
||||
POST(
|
||||
applyRequest({
|
||||
toolId: "opencode",
|
||||
baseUrl: catalogBaseUrl,
|
||||
apiKey: "sk-test-guard",
|
||||
dryRun: true,
|
||||
})
|
||||
)
|
||||
);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.dryRun, true);
|
||||
assert.ok(String(body.content).includes(catalogBaseUrl));
|
||||
assert.strictEqual(fs.existsSync(OPENCODE_CONFIG), false, "dry-run must not write");
|
||||
});
|
||||
|
||||
it("writes the valid OpenCode config on a host", async () => {
|
||||
const res = await withContainerMode("0", () =>
|
||||
POST(
|
||||
applyRequest({
|
||||
toolId: "opencode",
|
||||
baseUrl: catalogBaseUrl,
|
||||
apiKey: "sk-test-guard",
|
||||
})
|
||||
)
|
||||
);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.success, true);
|
||||
assert.strictEqual(body.configPath, OPENCODE_CONFIG);
|
||||
assert.ok(fs.existsSync(OPENCODE_CONFIG), "host write must land");
|
||||
const written = fs.readFileSync(OPENCODE_CONFIG, "utf-8");
|
||||
assert.ok(written.includes(catalogBaseUrl));
|
||||
});
|
||||
});
|
||||
@@ -93,3 +93,26 @@ test("completion scripts incluem combos/providers/models no cache dinamicamente"
|
||||
"should reference cache"
|
||||
);
|
||||
});
|
||||
|
||||
test("completion scripts expõem os alvos de execução e configuração", async () => {
|
||||
const { runCompletionCommand } = await import("../../bin/cli/commands/completion.mjs");
|
||||
const expected = ["connect", "contexts", "configure", "launch", "launch-codex", "run", "repair"];
|
||||
|
||||
for (const shell of ["bash", "zsh", "fish"] as const) {
|
||||
const chunks: string[] = [];
|
||||
const originalWrite = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = ((chunk: unknown) => {
|
||||
if (typeof chunk === "string") chunks.push(chunk);
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
assert.equal(await runCompletionCommand(shell), 0);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
const output = chunks.join("");
|
||||
for (const command of expected) {
|
||||
assert.ok(output.includes(command), `${shell} completion should include ${command}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
@@ -70,11 +70,59 @@ test("resolveActiveContext aceita override pontual", async () => {
|
||||
assert.equal(ctx.baseUrl, "http://staging:20128");
|
||||
});
|
||||
|
||||
test("saveContextsSecure guarda tokens no keychain e resolve pela referência", async () => {
|
||||
const {
|
||||
loadContexts,
|
||||
saveContextsSecure,
|
||||
resolveActiveContext,
|
||||
setContextKeychainBackendForTests,
|
||||
} = await import("../../bin/cli/contexts.mjs");
|
||||
const entries = new Map<string, string>();
|
||||
const fakeKeychain = {
|
||||
async getPassword(_service: string, account: string) {
|
||||
return entries.get(account) || null;
|
||||
},
|
||||
async setPassword(_service: string, account: string, value: string) {
|
||||
entries.set(account, value);
|
||||
},
|
||||
async deletePassword(_service: string, account: string) {
|
||||
entries.delete(account);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
await setContextKeychainBackendForTests(fakeKeychain);
|
||||
const cfg = loadContexts();
|
||||
cfg.contexts.secure = {
|
||||
baseUrl: "https://secure.example.com",
|
||||
accessToken: "oma_test_secret",
|
||||
scope: "write",
|
||||
};
|
||||
await saveContextsSecure(cfg);
|
||||
|
||||
const persisted = JSON.parse(readFileSync(join(tmpDir, "config.json"), "utf8"));
|
||||
assert.equal(persisted.contexts.secure.accessToken, undefined);
|
||||
assert.match(persisted.contexts.secure.credentialRef, /^omniroute-cli:context:/);
|
||||
assert.equal(resolveActiveContext("secure").accessToken, "oma_test_secret");
|
||||
assert.ok(entries.size >= 1);
|
||||
|
||||
await setContextKeychainBackendForTests(null);
|
||||
});
|
||||
|
||||
test("contexts.mjs (commands) pode ser importado sem erro", async () => {
|
||||
const mod = await import("../../bin/cli/commands/contexts.mjs");
|
||||
assert.equal(typeof mod.registerContexts, "function");
|
||||
});
|
||||
|
||||
test("context export redaction covers canonical and legacy profile schemas", async () => {
|
||||
const { redactContextSecrets } = await import("../../bin/cli/commands/contexts.mjs");
|
||||
const redacted = redactContextSecrets({
|
||||
contexts: { remote: { accessToken: "oma-secret", apiKey: "sk-secret" } },
|
||||
profiles: { legacy: { accessToken: "legacy-secret", apiKey: "legacy-key" } },
|
||||
});
|
||||
assert.deepEqual(redacted.contexts.remote, { apiKey: null });
|
||||
assert.deepEqual(redacted.profiles.legacy, { apiKey: null });
|
||||
});
|
||||
|
||||
test("confirm() declines cleanly on non-interactive stdin (no hung await)", async () => {
|
||||
// Regression: `contexts remove` without --yes used to prompt even when stdin
|
||||
// could not answer (pipe/CI/EOF), leaving the readline question pending and
|
||||
|
||||
143
tests/unit/cli-helper/config-generator-codex.test.ts
Normal file
143
tests/unit/cli-helper/config-generator-codex.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, after } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { parse } from "smol-toml";
|
||||
|
||||
import {
|
||||
generateCodexConfig,
|
||||
findLegacyCodexYaml,
|
||||
} from "../../../src/lib/cli-helper/config-generator/codex.ts";
|
||||
import { generateConfig } from "../../../src/lib/cli-helper/config-generator/index.ts";
|
||||
|
||||
interface ParsedCodexToml {
|
||||
model?: string;
|
||||
model_provider?: string;
|
||||
tool_output_token_limit?: number;
|
||||
model_providers: Record<
|
||||
string,
|
||||
{ name?: string; base_url?: string; env_key?: string; requires_openai_auth?: boolean }
|
||||
>;
|
||||
}
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
function tempCodexHome(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-gen-"));
|
||||
tmpDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
after(() => {
|
||||
for (const dir of tmpDirs) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("config-generator codex (TOML)", () => {
|
||||
it("generates modern TOML with env_key auth and never embeds the API key", async () => {
|
||||
const home = tempCodexHome();
|
||||
const content = await generateCodexConfig({
|
||||
baseUrl: "http://localhost:20128/",
|
||||
apiKey: "sk_live_secret_value",
|
||||
model: "glm/glm-5.2",
|
||||
configPath: path.join(home, "config.toml"),
|
||||
});
|
||||
|
||||
assert.ok(!content.includes("sk_live_secret_value"), "API key must not be written");
|
||||
const parsed = parse(content) as unknown as ParsedCodexToml;
|
||||
assert.strictEqual(parsed.model, "glm/glm-5.2");
|
||||
assert.strictEqual(parsed.model_provider, "omniroute");
|
||||
assert.strictEqual(parsed.model_providers.omniroute.base_url, "http://localhost:20128/v1");
|
||||
assert.strictEqual(parsed.model_providers.omniroute.env_key, "OMNIROUTE_API_KEY");
|
||||
assert.strictEqual(parsed.model_providers.omniroute.requires_openai_auth, false);
|
||||
});
|
||||
|
||||
it("normalizes a baseUrl that already ends in /v1", async () => {
|
||||
const home = tempCodexHome();
|
||||
const content = await generateCodexConfig({
|
||||
baseUrl: "https://relay.example.test/v1",
|
||||
apiKey: "sk-test",
|
||||
configPath: path.join(home, "config.toml"),
|
||||
});
|
||||
const parsed = parse(content) as unknown as ParsedCodexToml;
|
||||
assert.strictEqual(parsed.model_providers.omniroute.base_url, "https://relay.example.test/v1");
|
||||
assert.ok(!("model" in parsed), "model key is omitted when no model is chosen");
|
||||
});
|
||||
|
||||
it("merges conservatively with an existing config.toml, preserving operator keys", async () => {
|
||||
const home = tempCodexHome();
|
||||
const configPath = path.join(home, "config.toml");
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
'model = "old/model"',
|
||||
"tool_output_token_limit = 32768",
|
||||
"",
|
||||
"[model_providers.other]",
|
||||
'name = "Other"',
|
||||
'base_url = "https://other.example/v1"',
|
||||
].join("\n"),
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const content = await generateCodexConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "glm/glm-5.2",
|
||||
configPath,
|
||||
});
|
||||
const parsed = parse(content) as unknown as ParsedCodexToml;
|
||||
assert.strictEqual(parsed.tool_output_token_limit, 32768, "unrelated key preserved");
|
||||
assert.strictEqual(parsed.model_providers.other.name, "Other", "other provider preserved");
|
||||
assert.strictEqual(parsed.model, "glm/glm-5.2", "model updated");
|
||||
assert.strictEqual(parsed.model_provider, "omniroute");
|
||||
assert.ok(parsed.model_providers.omniroute, "omniroute provider added");
|
||||
});
|
||||
|
||||
it("refuses to overwrite an existing config.toml that is not valid TOML", async () => {
|
||||
const home = tempCodexHome();
|
||||
const configPath = path.join(home, "config.toml");
|
||||
fs.writeFileSync(configPath, "this is { not [ valid toml =", "utf-8");
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
generateCodexConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
configPath,
|
||||
}),
|
||||
/not valid TOML/
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(configPath, "utf-8"),
|
||||
"this is { not [ valid toml =",
|
||||
"invalid file left untouched"
|
||||
);
|
||||
});
|
||||
|
||||
it("detects a leftover legacy config.yaml for migration messaging", () => {
|
||||
const home = tempCodexHome();
|
||||
assert.strictEqual(findLegacyCodexYaml(home), null, "absent yaml → no migration");
|
||||
fs.writeFileSync(path.join(home, "config.yaml"), "openai:\n base_url: x\n", "utf-8");
|
||||
assert.strictEqual(findLegacyCodexYaml(home), path.join(home, "config.yaml"));
|
||||
});
|
||||
|
||||
it("generateConfig(codex) targets ~/.codex/config.toml (not the legacy yaml)", async () => {
|
||||
const result = await generateConfig("codex", {
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
// Success depends on the operator's real ~/.codex/config.toml being valid;
|
||||
// the path contract is what must hold either way.
|
||||
assert.ok(result.configPath.endsWith(path.join(".codex", "config.toml")));
|
||||
if (result.success) {
|
||||
assert.ok(String(result.content).includes("[model_providers.omniroute]"));
|
||||
assert.ok(!String(result.content).includes("sk-test"));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,16 @@ describe("config-generator", () => {
|
||||
assert.ok("configPath" in result);
|
||||
});
|
||||
|
||||
it("accepts the legacy kilocode id while generating the canonical kilo config", async () => {
|
||||
const result = await generator.generateConfig("kilocode", {
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(result.configPath.includes(".config/kilocode/settings.json"));
|
||||
assert.ok(String(result.content).includes("http://localhost:20128/v1"));
|
||||
});
|
||||
|
||||
it("returns success for valid hermes config", async () => {
|
||||
const result = await generator.generateConfig("hermes", {
|
||||
baseUrl: "http://localhost:20128",
|
||||
|
||||
@@ -61,10 +61,18 @@ describe("tool-detector", () => {
|
||||
assert.strictEqual(result!.version, "0.3.1");
|
||||
assert.ok(
|
||||
result!.configPath.includes(".openclaw/openclaw.json"),
|
||||
`expected configPath to include '.openclaw/openclaw.json', got: ${result!.configPath}`,
|
||||
`expected configPath to include '.openclaw/openclaw.json', got: ${result!.configPath}`
|
||||
);
|
||||
assert.strictEqual(typeof result!.configured, "boolean");
|
||||
});
|
||||
|
||||
it("normalizes the legacy kilocode id to the canonical kilo target", async () => {
|
||||
const result = await toolDetector.detectTool("kilocode");
|
||||
assert.ok(result !== null);
|
||||
assert.strictEqual(result!.id, "kilo");
|
||||
assert.strictEqual(result!.name, "Kilo Code");
|
||||
assert.ok(result!.configPath.includes(".local/share/kilo/auth.json"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectAllTools", () => {
|
||||
@@ -85,11 +93,14 @@ describe("tool-detector", () => {
|
||||
it("includes openclaw in the detected tools list", async () => {
|
||||
const tools = await toolDetector.detectAllTools();
|
||||
const openclaw = tools.find((t) => t.id === "openclaw");
|
||||
assert.ok(openclaw !== undefined, "detectAllTools() must include an entry with id='openclaw'");
|
||||
assert.ok(
|
||||
openclaw !== undefined,
|
||||
"detectAllTools() must include an entry with id='openclaw'"
|
||||
);
|
||||
assert.strictEqual(openclaw!.name, "OpenClaw");
|
||||
assert.ok(
|
||||
openclaw!.configPath.includes(".openclaw/openclaw.json"),
|
||||
`expected configPath to include '.openclaw/openclaw.json', got: ${openclaw!.configPath}`,
|
||||
`expected configPath to include '.openclaw/openclaw.json', got: ${openclaw!.configPath}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,7 +258,7 @@ test("createProgram wires the remote-mode commands into the real CLI program", a
|
||||
}
|
||||
const contexts = program.commands.find((c: any) => c.name() === "contexts");
|
||||
const subs = contexts.commands.map((c: any) => c.name());
|
||||
for (const sub of ["list", "use", "current"]) {
|
||||
for (const sub of ["list", "use", "current", "migrate"]) {
|
||||
assert.ok(subs.includes(sub), `expected 'contexts ${sub}' subcommand, got: ${subs.join(", ")}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const { getCliRuntimeStatus, getKnownToolPaths, CLI_TOOL_IDS } =
|
||||
const { getCliRuntimeStatus, getKnownToolPaths, normalizeCliToolId, CLI_TOOL_IDS } =
|
||||
await import("../../src/shared/services/cliRuntime.ts");
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
@@ -81,6 +81,16 @@ describe("CLI_TOOL_IDS", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI tool id compatibility aliases", () => {
|
||||
it("normalizes legacy binary names without creating duplicate ids", () => {
|
||||
assert.equal(normalizeCliToolId("kilocode"), "kilo");
|
||||
assert.equal(normalizeCliToolId("kilo-code"), "kilo");
|
||||
assert.equal(normalizeCliToolId("openai-codex"), "codex");
|
||||
assert.equal(normalizeCliToolId("cc"), "claude");
|
||||
assert.equal(normalizeCliToolId("unknown-tool"), "unknown-tool");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Size Threshold (30 bytes) ────────────────────────────────
|
||||
|
||||
describe("Size threshold — checkKnownPath", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
normalizeCliCompatProviderId,
|
||||
} = await import("../../src/shared/constants/cliCompatProviders.ts");
|
||||
const { CLI_TOOL_IDS } = await import("../../src/shared/services/cliRuntime.ts");
|
||||
const { hasRegisteredAgent } = await import("../../src/lib/acp/registry.ts");
|
||||
const { applyFingerprint, isCliCompatEnabled, setCliCompatProviders } =
|
||||
await import("../../open-sse/config/cliFingerprints.ts");
|
||||
|
||||
@@ -31,6 +32,11 @@ test("Hermes quick-config is registered as a guide-based CLI tool", () => {
|
||||
assert.ok(CLI_TOOL_IDS.includes("hermes"));
|
||||
});
|
||||
|
||||
test("ACP registry accepts the Gemini CLI target used by the manager", () => {
|
||||
assert.equal(hasRegisteredAgent("gemini"), true);
|
||||
assert.equal(hasRegisteredAgent("definitely-not-an-agent"), false);
|
||||
});
|
||||
|
||||
test("CLI fingerprint toggles only expose implemented fingerprints and functional legacy aliases", () => {
|
||||
const implemented = new Set<string>(IMPLEMENTED_CLI_FINGERPRINT_PROVIDER_IDS);
|
||||
|
||||
|
||||
127
tests/unit/cli/cli-manifest-drift.test.ts
Normal file
127
tests/unit/cli/cli-manifest-drift.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
CLI_TARGET_MANIFEST,
|
||||
listManifestTargets,
|
||||
manifestModelArgs,
|
||||
manifestRequiresModel,
|
||||
resolveManifestTarget,
|
||||
} from "../../../bin/cli/cli-manifest.mjs";
|
||||
import { listRunTargets, resolveRunTarget } from "../../../bin/cli/commands/run.mjs";
|
||||
import { listConfigureTargets, SETUP_MODULES } from "../../../bin/cli/commands/configure.mjs";
|
||||
import { runCompletionCommand } from "../../../bin/cli/commands/completion.mjs";
|
||||
import {
|
||||
CLI_TOOL_IDS,
|
||||
CLI_TOOL_ALIASES,
|
||||
normalizeCliToolId,
|
||||
getCliConfigPaths,
|
||||
} from "../../../src/shared/services/cliRuntime";
|
||||
import { getCliTool } from "../../../src/shared/constants/cliTools";
|
||||
|
||||
/**
|
||||
* Drift guard for the executable manifest (`bin/cli/cli-manifest.mjs`).
|
||||
*
|
||||
* The manifest is the single declaration of which targets `omniroute run` /
|
||||
* `omniroute configure` / shell completion expose. These assertions fail as
|
||||
* soon as any consumer surface — or the server-side runtime catalog — starts
|
||||
* disagreeing with it silently.
|
||||
*/
|
||||
|
||||
const manifestIds = Object.keys(CLI_TARGET_MANIFEST);
|
||||
|
||||
test("every manifest target is a canonical id in the runtime catalog", () => {
|
||||
for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) {
|
||||
assert.equal(normalizeCliToolId(id), id, `${id} must be canonical (not an alias)`);
|
||||
assert.ok(CLI_TOOL_IDS.includes(id), `${id} must exist in cliRuntime CLI_TOOLS`);
|
||||
assert.ok(getCliConfigPaths(id), `${id} must resolve config paths in the runtime`);
|
||||
// Configure targets surface in the dashboard picker flows, so they must be
|
||||
// cataloged for the UI. Run-only targets (e.g. gemini) may stay CLI-only.
|
||||
if (entry.configure) {
|
||||
assert.ok(getCliTool(id), `${id} must exist in the UI catalog (cliTools.ts)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("manifest aliases never conflict with runtime aliases", () => {
|
||||
for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) {
|
||||
for (const alias of entry.aliases) {
|
||||
const runtimeTarget = CLI_TOOL_ALIASES[alias];
|
||||
if (runtimeTarget !== undefined) {
|
||||
assert.equal(
|
||||
runtimeTarget,
|
||||
id,
|
||||
`alias '${alias}' maps to '${id}' in the manifest but '${runtimeTarget}' in cliRuntime`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("kilocode variants stay a single canonical target in both worlds", () => {
|
||||
for (const legacy of ["kilocode", "kilo-code", "kilo_cli"]) {
|
||||
assert.equal(resolveManifestTarget(legacy, "configure"), "kilo");
|
||||
assert.equal(normalizeCliToolId(legacy), "kilo");
|
||||
}
|
||||
});
|
||||
|
||||
test("run command derives targets and aliases from the manifest", () => {
|
||||
assert.deepEqual(listRunTargets(), listManifestTargets("run"));
|
||||
for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) {
|
||||
const expected = entry.run ? id : undefined;
|
||||
assert.equal(resolveRunTarget(id), expected, `resolveRunTarget(${id})`);
|
||||
for (const alias of entry.aliases) {
|
||||
assert.equal(resolveRunTarget(alias), expected, `resolveRunTarget(${alias})`);
|
||||
}
|
||||
}
|
||||
assert.equal(resolveRunTarget("definitely-not-a-cli"), undefined);
|
||||
});
|
||||
|
||||
test("configure command derives targets from the manifest and has a recipe per target", () => {
|
||||
assert.deepEqual(listConfigureTargets(), listManifestTargets("configure"));
|
||||
for (const id of listManifestTargets("configure")) {
|
||||
const hasRecipe = id === "codex" || Boolean(SETUP_MODULES[id]);
|
||||
assert.ok(hasRecipe, `configure target '${id}' has no setup recipe`);
|
||||
}
|
||||
for (const id of Object.keys(SETUP_MODULES)) {
|
||||
assert.ok(
|
||||
listManifestTargets("configure").includes(id),
|
||||
`setup recipe '${id}' is not a manifest configure target`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("completion scripts embed the manifest-derived target lists", async () => {
|
||||
const runWords = listManifestTargets("run").join(" ");
|
||||
const configureWords = listManifestTargets("configure").join(" ");
|
||||
|
||||
for (const shell of ["bash", "zsh", "fish"] as const) {
|
||||
const chunks: string[] = [];
|
||||
const originalWrite = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = ((chunk: unknown) => {
|
||||
if (typeof chunk === "string") chunks.push(chunk);
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
assert.equal(await runCompletionCommand(shell), 0);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
const output = chunks.join("");
|
||||
assert.ok(output.includes(runWords), `${shell} completion must list run targets`);
|
||||
assert.ok(output.includes(configureWords), `${shell} completion must list configure targets`);
|
||||
}
|
||||
});
|
||||
|
||||
test("model-flag wiring stays declared in the manifest", () => {
|
||||
assert.deepEqual(manifestModelArgs("aider", "glm/glm-5.2"), ["--model", "openai/glm/glm-5.2"]);
|
||||
assert.deepEqual(manifestModelArgs("opencode", "glm/glm-5.2"), [
|
||||
"--model",
|
||||
"omniroute/glm/glm-5.2",
|
||||
]);
|
||||
assert.deepEqual(manifestModelArgs("qwen", "glm/glm-5.2"), ["--model", "glm/glm-5.2"]);
|
||||
assert.deepEqual(manifestModelArgs("claude", "glm/glm-5.2"), []);
|
||||
assert.deepEqual(manifestModelArgs("codex", "glm/glm-5.2"), []);
|
||||
assert.equal(manifestRequiresModel("qwen"), true);
|
||||
assert.equal(manifestRequiresModel("aider"), false);
|
||||
});
|
||||
76
tests/unit/cli/configure-command.test.ts
Normal file
76
tests/unit/cli/configure-command.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
listConfigureTargets,
|
||||
profileNameFromModel,
|
||||
resolveConfigureTargetOptions,
|
||||
rankPreferredModels,
|
||||
getModelPreferenceState,
|
||||
} = await import("../../../bin/cli/commands/configure.mjs");
|
||||
|
||||
test("configure picker exposes setup-backed CLI targets (manifest declaration order)", () => {
|
||||
assert.deepEqual(listConfigureTargets(), [
|
||||
"claude",
|
||||
"codex",
|
||||
"aider",
|
||||
"goose",
|
||||
"opencode",
|
||||
"qwen",
|
||||
"cline",
|
||||
"continue",
|
||||
"kilo",
|
||||
]);
|
||||
});
|
||||
|
||||
test("configure picker derives stable profile names from provider/model ids", () => {
|
||||
assert.equal(profileNameFromModel("glm/glm-5.2"), "glm52");
|
||||
assert.equal(profileNameFromModel("claude-sonnet-4.6"), "claudesonnet46");
|
||||
});
|
||||
|
||||
test("configure picker materializes explicit remote/base-url targets", () => {
|
||||
assert.deepEqual(
|
||||
resolveConfigureTargetOptions({
|
||||
baseUrl: "https://relay.example.test/v1",
|
||||
apiKey: "sk_test",
|
||||
port: "2999",
|
||||
}),
|
||||
{
|
||||
baseUrl: "https://relay.example.test/v1",
|
||||
remote: "https://relay.example.test/v1",
|
||||
apiKey: "sk_test",
|
||||
port: "2999",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("configure picker ranks favorites and recent model ids without leaking context data", () => {
|
||||
const ranked = rankPreferredModels("codex", ["glm/slow", "glm/fast", "qwen/recent"], {
|
||||
targets: { codex: { favorites: ["glm/fast"], recent: ["qwen/recent"] } },
|
||||
});
|
||||
assert.deepEqual(ranked, ["glm/fast", "qwen/recent", "glm/slow"]);
|
||||
assert.deepEqual(
|
||||
getModelPreferenceState("codex", {
|
||||
targets: { codex: { favorites: ["glm/fast"], recent: ["qwen/recent"] } },
|
||||
}),
|
||||
{ favorites: ["glm/fast"], recent: ["qwen/recent"] }
|
||||
);
|
||||
});
|
||||
|
||||
test("configure picker keeps preferences isolated per remote context", () => {
|
||||
const preferences = {
|
||||
targets: {},
|
||||
contexts: {
|
||||
local: { codex: { favorites: ["local/model"], recent: [] } },
|
||||
remote: { codex: { favorites: ["remote/model"], recent: [] } },
|
||||
},
|
||||
};
|
||||
assert.deepEqual(
|
||||
rankPreferredModels("codex", ["local/model", "remote/model"], preferences, "remote"),
|
||||
["remote/model", "local/model"]
|
||||
);
|
||||
assert.deepEqual(getModelPreferenceState("codex", preferences, "local"), {
|
||||
favorites: ["local/model"],
|
||||
recent: [],
|
||||
});
|
||||
});
|
||||
136
tests/unit/cli/provider-crud.test.ts
Normal file
136
tests/unit/cli/provider-crud.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildProviderPayload,
|
||||
findConnectionFromResponse,
|
||||
redactProviderResponse,
|
||||
resolveProviderCredential,
|
||||
runProviderAddCommand,
|
||||
} from "../../../bin/cli/commands/provider-crud.mjs";
|
||||
|
||||
test("provider payload separates management auth from provider credential", () => {
|
||||
const payload = buildProviderPayload(
|
||||
"glm",
|
||||
{
|
||||
name: "work",
|
||||
defaultModel: "glm/glm-5.2",
|
||||
priority: "2",
|
||||
providerSpecificData: '{"region":"global"}',
|
||||
apiKey: "management-token-that-must-not-be-used",
|
||||
},
|
||||
"provider-secret"
|
||||
);
|
||||
|
||||
assert.deepEqual(payload, {
|
||||
provider: "glm",
|
||||
name: "work",
|
||||
apiKey: "provider-secret",
|
||||
defaultModel: "glm/glm-5.2",
|
||||
priority: 2,
|
||||
providerSpecificData: { region: "global" },
|
||||
});
|
||||
});
|
||||
|
||||
test("provider selector resolves id, prefix, name, and provider", () => {
|
||||
const body = {
|
||||
connections: [
|
||||
{ id: "abc-123", name: "Work GLM", provider: "glm" },
|
||||
{ id: "def-456", name: "OpenAI", provider: "openai" },
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(findConnectionFromResponse(body, "abc-123")?.name, "Work GLM");
|
||||
assert.equal(findConnectionFromResponse(body, "def")?.name, "OpenAI");
|
||||
assert.equal(findConnectionFromResponse(body, "work glm")?.id, "abc-123");
|
||||
assert.equal(findConnectionFromResponse(body, "openai")?.id, "def-456");
|
||||
assert.equal(findConnectionFromResponse(body, "missing"), null);
|
||||
});
|
||||
|
||||
test("provider credential can be resolved from a validated environment name", async () => {
|
||||
const previous = process.env.TEST_PROVIDER_SECRET;
|
||||
process.env.TEST_PROVIDER_SECRET = "secret-from-env";
|
||||
try {
|
||||
assert.equal(
|
||||
await resolveProviderCredential({ credentialEnv: "TEST_PROVIDER_SECRET" }, { prompt: false }),
|
||||
"secret-from-env"
|
||||
);
|
||||
await assert.rejects(
|
||||
resolveProviderCredential({ credentialEnv: "bad-name;rm" }, { prompt: false }),
|
||||
/valid env name/
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.TEST_PROVIDER_SECRET;
|
||||
else process.env.TEST_PROVIDER_SECRET = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("dry-run credential resolution never prompts or requires a secret", async () => {
|
||||
assert.equal(await resolveProviderCredential({}, { prompt: false }), undefined);
|
||||
assert.deepEqual(buildProviderPayload("glm", { name: "work" }, undefined), {
|
||||
provider: "glm",
|
||||
name: "work",
|
||||
});
|
||||
});
|
||||
|
||||
test("negated --no-credential is treated as a control flag, not the literal string", async () => {
|
||||
assert.equal(
|
||||
await resolveProviderCredential({ credential: false }, { prompt: false }),
|
||||
undefined
|
||||
);
|
||||
assert.deepEqual(buildProviderPayload("ollama", { name: "local" }, undefined), {
|
||||
provider: "ollama",
|
||||
name: "local",
|
||||
});
|
||||
});
|
||||
|
||||
test("provider JSON output redacts raw credentials recursively", () => {
|
||||
const redacted = redactProviderResponse({
|
||||
connection: {
|
||||
id: "conn-1",
|
||||
apiKey: "provider-secret",
|
||||
providerSpecificData: { client_secret: "oauth-secret" },
|
||||
credentialRef: "omniroute-cli:context:remote",
|
||||
},
|
||||
token: "management-secret",
|
||||
});
|
||||
|
||||
assert.deepEqual(redacted, {
|
||||
connection: {
|
||||
id: "conn-1",
|
||||
apiKey: { present: true, length: 15 },
|
||||
providerSpecificData: { client_secret: { present: true, length: 12 } },
|
||||
credentialRef: "omniroute-cli:context:remote",
|
||||
},
|
||||
token: { present: true, length: 17 },
|
||||
});
|
||||
});
|
||||
|
||||
test("provider OAuth dry-run never starts a browser or mutates the server", async () => {
|
||||
assert.equal(
|
||||
await runProviderAddCommand("openai", { oauth: true, dryRun: true, silent: true }),
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test("provider add dry-run redacts provider-specific secrets", async () => {
|
||||
const output: string[] = [];
|
||||
const originalLog = console.log;
|
||||
console.log = (...args: unknown[]) => output.push(args.join(" "));
|
||||
try {
|
||||
assert.equal(
|
||||
await runProviderAddCommand("glm", {
|
||||
dryRun: true,
|
||||
yes: true,
|
||||
json: true,
|
||||
providerSpecificData: JSON.stringify({ client_secret: "oauth-secret" }),
|
||||
}),
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
const serialized = output.join("\n");
|
||||
assert.ok(!serialized.includes("oauth-secret"));
|
||||
assert.match(serialized, /client_secret/);
|
||||
});
|
||||
@@ -13,8 +13,10 @@ test("resolveRunTarget resolves aliases", () => {
|
||||
assert.equal(resolveRunTarget("CLAUDE-CODE"), "claude");
|
||||
assert.equal(resolveRunTarget("cc"), "claude");
|
||||
assert.equal(resolveRunTarget("codex"), "codex");
|
||||
assert.equal(resolveRunTarget("codex-cli"), "codex");
|
||||
assert.equal(resolveRunTarget("openai-codex"), "codex");
|
||||
assert.equal(resolveRunTarget("openai"), "codex");
|
||||
assert.equal(resolveRunTarget("anthropic"), "claude");
|
||||
assert.equal(resolveRunTarget("unknown"), undefined);
|
||||
});
|
||||
|
||||
@@ -60,6 +62,78 @@ test("buildRunPlan for codex injects model into provider args", async () => {
|
||||
assert.equal(plan.authSource, "option");
|
||||
});
|
||||
|
||||
test("buildRunPlan for Aider uses its OpenAI-compatible root endpoint", async () => {
|
||||
const plan = await buildRunPlan(
|
||||
"aider",
|
||||
{ remote: "https://relay.example.test/v1", apiKey: "sk_test_x", model: "glm/glm-5.2" },
|
||||
["--message", "reply OK"]
|
||||
);
|
||||
assert.equal(plan.target, "aider");
|
||||
assert.equal(plan.baseUrl, "https://relay.example.test");
|
||||
assert.deepEqual(plan.args.slice(0, 2), ["--model", "openai/glm/glm-5.2"]);
|
||||
assert.equal(plan.envDiff.changedOrAdded.includes("OPENAI_API_BASE"), true);
|
||||
assert.equal(plan.envDiff.changedOrAdded.includes("OPENAI_API_KEY"), true);
|
||||
});
|
||||
|
||||
test("buildRunPlan for Goose injects provider and model without writing config", async () => {
|
||||
const plan = await buildRunPlan(
|
||||
"goose-cli",
|
||||
{ baseUrl: "http://localhost:20128", apiKey: "sk_test_x", model: "glm/glm-5.2" },
|
||||
["session"]
|
||||
);
|
||||
assert.equal(plan.target, "goose");
|
||||
assert.deepEqual(plan.args, ["session"]);
|
||||
assert.equal(plan.envDiff.changedOrAdded.includes("GOOSE_PROVIDER"), true);
|
||||
assert.equal(plan.envDiff.changedOrAdded.includes("GOOSE_MODEL"), true);
|
||||
assert.equal(plan.envDiff.changedOrAdded.includes("OPENAI_HOST"), true);
|
||||
});
|
||||
|
||||
test("buildRunPlan for OpenCode uses an ephemeral compatible config", async () => {
|
||||
const plan = await buildRunPlan(
|
||||
"open-code",
|
||||
{ baseUrl: "https://relay.example.test", apiKey: "sk_test_x", model: "glm/glm-5.2" },
|
||||
["run", "reply OK"]
|
||||
);
|
||||
assert.equal(plan.target, "opencode");
|
||||
assert.deepEqual(plan.args.slice(0, 2), ["--model", "omniroute/glm/glm-5.2"]);
|
||||
assert.equal(plan.envDiff.changedOrAdded.includes("OPENCODE_CONFIG_CONTENT"), true);
|
||||
assert.equal(plan.envDiff.changedOrAdded.includes("OMNIROUTE_API_KEY"), true);
|
||||
assert.equal(plan.configOverlay, "OPENCODE_CONFIG_CONTENT (process environment only)");
|
||||
assert.equal(JSON.stringify(plan).includes("sk_test_x"), false);
|
||||
});
|
||||
|
||||
test("buildRunPlan for Qwen requires a deterministic model and injects only env names", async () => {
|
||||
const plan = await buildRunPlan(
|
||||
"qwen-code",
|
||||
{ baseUrl: "https://relay.example.test", apiKey: "sk_test_x", model: "glm/glm-5.2" },
|
||||
["-p", "reply OK"]
|
||||
);
|
||||
assert.equal(plan.target, "qwen");
|
||||
assert.deepEqual(plan.args.slice(0, 2), ["--model", "glm/glm-5.2"]);
|
||||
assert.equal(plan.envDiff.changedOrAdded.includes("OMNIROUTE_API_KEY"), true);
|
||||
assert.equal(plan.configOverlay, "temporary QWEN_HOME (removed after exit)");
|
||||
await assert.rejects(
|
||||
() => buildRunPlan("qwen", { baseUrl: "https://relay.example.test", apiKey: "sk_test_x" }),
|
||||
/requires --model/
|
||||
);
|
||||
});
|
||||
|
||||
test("buildRunPlan for Gemini points the CLI at the /v1beta surface via env", async () => {
|
||||
const plan = await buildRunPlan(
|
||||
"gemini-cli",
|
||||
{ baseUrl: "https://relay.example.test", apiKey: "sk_test_x", model: "glm/glm-5.2" },
|
||||
["-p", "reply OK"]
|
||||
);
|
||||
assert.equal(plan.target, "gemini");
|
||||
assert.equal(plan.baseUrl, "https://relay.example.test");
|
||||
assert.deepEqual(plan.args.slice(0, 2), ["--model", "glm/glm-5.2"]);
|
||||
assert.equal(plan.envDiff.changedOrAdded.includes("GOOGLE_GEMINI_BASE_URL"), true);
|
||||
assert.equal(plan.envDiff.changedOrAdded.includes("GEMINI_API_KEY"), true);
|
||||
assert.equal(plan.envDiff.changedOrAdded.includes("GEMINI_DEFAULT_AUTH_TYPE"), true);
|
||||
assert.equal(plan.configOverlay, "temporary GEMINI_CLI_HOME (removed after exit)");
|
||||
assert.equal(JSON.stringify(plan).includes("sk_test_x"), false);
|
||||
});
|
||||
|
||||
test("runCliTarget returns usage error code for unsupported targets", async () => {
|
||||
const seen = [];
|
||||
const originalWrite = process.stderr.write;
|
||||
@@ -99,3 +173,17 @@ test("dry-run --json does not print resolved auth token", async () => {
|
||||
const raw = chunks.join("");
|
||||
assert.equal(raw.includes("sk_live_very_private_token"), false);
|
||||
});
|
||||
|
||||
test("--api-key-env resolves credentials without exposing their value in the plan", async () => {
|
||||
const previous = process.env.OMNIROUTE_RUN_TEST_TOKEN;
|
||||
process.env.OMNIROUTE_RUN_TEST_TOKEN = "sk_env_private";
|
||||
try {
|
||||
const plan = await buildRunPlan("codex-cli", { apiKeyEnv: "OMNIROUTE_RUN_TEST_TOKEN" });
|
||||
assert.equal(plan.authSource, "env");
|
||||
assert.equal(plan.envDiff.changedOrAdded.includes("OMNIROUTE_API_KEY"), true);
|
||||
assert.equal(JSON.stringify(plan).includes("sk_env_private"), false);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.OMNIROUTE_RUN_TEST_TOKEN;
|
||||
else process.env.OMNIROUTE_RUN_TEST_TOKEN = previous;
|
||||
}
|
||||
});
|
||||
|
||||
170
tests/unit/cli/run-execution.test.ts
Normal file
170
tests/unit/cli/run-execution.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { runCliTarget } from "../../../bin/cli/commands/run.mjs";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
async function makeFakeCli(name: string, body: string) {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "omniroute-run-cli-"));
|
||||
const file = path.join(dir, name);
|
||||
await writeFile(file, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 });
|
||||
await chmod(file, 0o755);
|
||||
return { dir, file };
|
||||
}
|
||||
|
||||
async function withReachableOmniRoute<T>(run: () => Promise<T>): Promise<T> {
|
||||
globalThis.fetch = async () => new Response("{}", { status: 200 });
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
test("run executes a generic target with isolated env and propagates its exit code", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("POSIX fake executable; Windows shim behavior is covered by launch tests");
|
||||
return;
|
||||
}
|
||||
|
||||
const capture = await mkdtemp(path.join(os.tmpdir(), "omniroute-run-capture-"));
|
||||
const capturePath = path.join(capture, "aider.json");
|
||||
const fake = await makeFakeCli(
|
||||
"aider",
|
||||
`const fs = await import("node:fs");
|
||||
fs.writeFileSync(process.env.CAPTURE_PATH, JSON.stringify({
|
||||
argv: process.argv.slice(2),
|
||||
base: process.env.OPENAI_API_BASE,
|
||||
key: process.env.OPENAI_API_KEY,
|
||||
}));
|
||||
process.exit(7);`
|
||||
);
|
||||
process.env.PATH = `${fake.dir}${path.delimiter}${originalPath || ""}`;
|
||||
process.env.CAPTURE_PATH = capturePath;
|
||||
|
||||
try {
|
||||
const code = await withReachableOmniRoute(() =>
|
||||
runCliTarget(
|
||||
"aider",
|
||||
{ remote: "https://relay.example.test", apiKey: "sk_private", model: "glm/glm-5.2" },
|
||||
["--message", "reply OK"]
|
||||
)
|
||||
);
|
||||
assert.equal(code, 7);
|
||||
const result = JSON.parse(await readFile(capturePath, "utf8"));
|
||||
assert.deepEqual(result.argv.slice(0, 2), ["--model", "openai/glm/glm-5.2"]);
|
||||
assert.deepEqual(result.argv.slice(2), ["--message", "reply OK"]);
|
||||
assert.equal(result.base, "https://relay.example.test");
|
||||
assert.equal(result.key, "sk_private");
|
||||
} finally {
|
||||
if (originalPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = originalPath;
|
||||
delete process.env.CAPTURE_PATH;
|
||||
await rm(fake.dir, { recursive: true, force: true });
|
||||
await rm(capture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("run gives Gemini an isolated GEMINI_CLI_HOME forcing api-key auth and removes it", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("POSIX fake executable; Windows shim behavior is covered by launch tests");
|
||||
return;
|
||||
}
|
||||
|
||||
const capture = await mkdtemp(path.join(os.tmpdir(), "omniroute-run-gemini-capture-"));
|
||||
const capturePath = path.join(capture, "gemini.json");
|
||||
const fake = await makeFakeCli(
|
||||
"gemini",
|
||||
`const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const home = process.env.GEMINI_CLI_HOME;
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(home, ".gemini", "settings.json"), "utf8"));
|
||||
fs.writeFileSync(process.env.CAPTURE_PATH, JSON.stringify({
|
||||
home,
|
||||
argv: process.argv.slice(2),
|
||||
baseUrl: process.env.GOOGLE_GEMINI_BASE_URL,
|
||||
key: process.env.GEMINI_API_KEY,
|
||||
defaultAuth: process.env.GEMINI_DEFAULT_AUTH_TYPE,
|
||||
selectedType: settings.security?.auth?.selectedType,
|
||||
}));`
|
||||
);
|
||||
process.env.PATH = `${fake.dir}${path.delimiter}${originalPath || ""}`;
|
||||
process.env.CAPTURE_PATH = capturePath;
|
||||
|
||||
try {
|
||||
const code = await withReachableOmniRoute(() =>
|
||||
runCliTarget(
|
||||
"gemini",
|
||||
{ remote: "https://relay.example.test", apiKey: "sk_private", model: "glm/glm-5.2" },
|
||||
["-p", "reply OK"]
|
||||
)
|
||||
);
|
||||
assert.equal(code, 0);
|
||||
const result = JSON.parse(await readFile(capturePath, "utf8"));
|
||||
assert.deepEqual(result.argv, ["--model", "glm/glm-5.2", "-p", "reply OK"]);
|
||||
assert.equal(result.baseUrl, "https://relay.example.test");
|
||||
assert.equal(result.key, "sk_private");
|
||||
assert.equal(result.defaultAuth, "gemini-api-key");
|
||||
assert.equal(result.selectedType, "gemini-api-key");
|
||||
assert.equal(existsSync(result.home), false);
|
||||
} finally {
|
||||
if (originalPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = originalPath;
|
||||
delete process.env.CAPTURE_PATH;
|
||||
await rm(fake.dir, { recursive: true, force: true });
|
||||
await rm(capture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("run gives Qwen an isolated temporary home and removes it after exit", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("POSIX fake executable; Windows shim behavior is covered by launch tests");
|
||||
return;
|
||||
}
|
||||
|
||||
const capture = await mkdtemp(path.join(os.tmpdir(), "omniroute-run-qwen-capture-"));
|
||||
const capturePath = path.join(capture, "qwen.json");
|
||||
const fake = await makeFakeCli(
|
||||
"qwen",
|
||||
`const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const home = process.env.QWEN_HOME;
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(home, "settings.json"), "utf8"));
|
||||
fs.writeFileSync(process.env.CAPTURE_PATH, JSON.stringify({
|
||||
home,
|
||||
argv: process.argv.slice(2),
|
||||
model: settings.model?.name,
|
||||
baseUrl: settings.model?.baseUrl,
|
||||
}));`
|
||||
);
|
||||
process.env.PATH = `${fake.dir}${path.delimiter}${originalPath || ""}`;
|
||||
process.env.CAPTURE_PATH = capturePath;
|
||||
|
||||
try {
|
||||
const code = await withReachableOmniRoute(() =>
|
||||
runCliTarget(
|
||||
"qwen",
|
||||
{ remote: "https://relay.example.test", apiKey: "sk_private", model: "glm/glm-5.2" },
|
||||
["-p", "reply OK"]
|
||||
)
|
||||
);
|
||||
assert.equal(code, 0);
|
||||
const result = JSON.parse(await readFile(capturePath, "utf8"));
|
||||
assert.deepEqual(result.argv, ["--model", "glm/glm-5.2", "-p", "reply OK"]);
|
||||
assert.equal(result.model, "glm/glm-5.2");
|
||||
assert.equal(result.baseUrl, "https://relay.example.test/v1");
|
||||
assert.equal(existsSync(result.home), false);
|
||||
} finally {
|
||||
if (originalPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = originalPath;
|
||||
delete process.env.CAPTURE_PATH;
|
||||
await rm(fake.dir, { recursive: true, force: true });
|
||||
await rm(capture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user