From 165d9cdae9c1a55aa36877eb28e59b71e461c595 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:49:47 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20setup-aider=20=E2=80=94=20configur?= =?UTF-8?q?e=20Aider=20for=20OmniRoute=20(.aider.conf.yml=20+=20env=20reci?= =?UTF-8?q?pe)=20(#4302)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI #12 of the series. `omniroute setup-aider` writes Aider's ~/.aider.conf.yml (openai-api-base = ROOT url, NO /v1 — LiteLLM appends /v1/chat/completions — model: openai/), merges to preserve existing config, and prints the env recipe (OPENAI_API_BASE + OPENAI_API_KEY in the env, never the file) plus the headless command (aider --message ... --yes). Remote-aware; model via --model or interactive pick. Researched against aider.chat: OpenAI-compatible via OPENAI_API_BASE (base, no /v1) + --model openai/. Aider's wire (/v1/chat/completions) already validated → "OK". Tests: resolveAiderTarget (/v1 strip, key), buildAiderConfig (openai-api-base + openai/ + preserve), buildAiderRecipe (env-ref key + headless). 4 unit tests; cli-i18n green. --- bin/cli/commands/registry.mjs | 2 + bin/cli/commands/setup-aider.mjs | 146 +++++++++++++++++++++++++++++ docs/guides/REMOTE-MODE.md | 1 + tests/unit/cli/setup-aider.test.ts | 24 +++++ 4 files changed, 173 insertions(+) create mode 100644 bin/cli/commands/setup-aider.mjs create mode 100644 tests/unit/cli/setup-aider.test.ts diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 8ef828e347..937888bc2d 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -68,6 +68,7 @@ import { registerSetupRoo } from "./setup-roo.mjs"; import { registerSetupCrush } from "./setup-crush.mjs"; import { registerSetupGoose } from "./setup-goose.mjs"; import { registerSetupQwen } from "./setup-qwen.mjs"; +import { registerSetupAider } from "./setup-aider.mjs"; import { registerConnect } from "./connect.mjs"; import { registerTokens } from "./tokens.mjs"; import { registerConfigure } from "./configure.mjs"; @@ -146,6 +147,7 @@ export function registerCommands(program) { registerSetupCrush(program); registerSetupGoose(program); registerSetupQwen(program); + registerSetupAider(program); registerConnect(program); registerTokens(program); registerConfigure(program); diff --git a/bin/cli/commands/setup-aider.mjs b/bin/cli/commands/setup-aider.mjs new file mode 100644 index 0000000000..f9c0b5c8bb --- /dev/null +++ b/bin/cli/commands/setup-aider.mjs @@ -0,0 +1,146 @@ +/** + * omniroute setup-aider — configure Aider (aider.chat) for OmniRoute. + * + * Aider (LiteLLM under the hood) talks to an OpenAI-compatible endpoint via env + * `OPENAI_API_BASE` (ROOT url — LiteLLM appends /v1/chat/completions) + the model + * flag `--model openai/`. This writes ~/.aider.conf.yml (openai-api-base + + * model) — the key stays in OPENAI_API_KEY (env, never the file) — and prints the + * guaranteed env recipe + headless command. Remote-aware. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import os from "node:os"; +import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; +import { resolveActiveContext } from "../contexts.mjs"; + +function stripToRoot(url) { + const s = String(url || "").replace(/\/+$/, ""); + return s.endsWith("/v1") ? s.slice(0, -3) : s; +} + +/** Resolve OPENAI_API_BASE (ROOT, no /v1 — LiteLLM appends) + apiKey. */ +export function resolveAiderTarget(opts = {}) { + let root; + if (opts.remote) root = stripToRoot(opts.remote); + else { + try { + root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl); + } catch { + /* none */ + } + if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`; + } + let apiKey = opts.apiKey ?? opts["api-key"]; + if (!apiKey) { + try { + const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); + apiKey = c?.accessToken || c?.apiKey; + } catch { + /* none */ + } + } + if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || ""; + return { apiBase: root, apiKey }; +} + +/** Merge openai-api-base + model into an .aider.conf.yml object (preserve rest). */ +export function buildAiderConfig(existing, { apiBase, model }) { + const cfg = existing && typeof existing === "object" ? { ...existing } : {}; + cfg["openai-api-base"] = apiBase; + if (model) cfg.model = `openai/${model}`; + return cfg; +} + +/** The guaranteed env + run recipe (pure → testable). */ +export function buildAiderRecipe({ apiBase, model }) { + return [ + `export OPENAI_API_BASE=${apiBase}`, + "export OPENAI_API_KEY=$OMNIROUTE_API_KEY", + `aider --model openai/${model}`, + `# headless: aider --model openai/${model} --message "reply OK" --yes`, + ].join("\n"); +} + +function readYamlSafe(yaml, path) { + try { + if (existsSync(path)) return yaml.load(readFileSync(path, "utf8")) || {}; + } catch { + /* corrupt/missing */ + } + return {}; +} + +async function fetchModelIds(apiBase, apiKey) { + try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch(`${apiBase}/v1/models`, { headers, signal: AbortSignal.timeout(8000) }); + if (!res.ok) return []; + const body = await res.json(); + const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; + return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); + } catch { + return []; + } +} + +export async function runSetupAiderCommand(opts = {}) { + const { apiBase, apiKey } = resolveAiderTarget(opts); + const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); + const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".aider.conf.yml"); + + printHeading("OmniRoute → Aider (openai-compatible via LiteLLM)"); + printInfo(`OPENAI_API_BASE: ${apiBase} (no /v1 — LiteLLM appends it)`); + + let model = opts.model; + if (!model) { + const ids = await fetchModelIds(apiBase, apiKey); + if (ids.length && !opts.yes) { + printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`); + const prompt = createPrompt(); + try { + model = await prompt.ask("Model id for Aider (without the openai/ prefix)"); + } finally { + prompt.close(); + } + } + } + if (!model) { + printError("A model is required. Pass --model (the openai/ prefix is added automatically)."); + return 2; + } + + const yaml = await import("js-yaml"); + const merged = buildAiderConfig(readYamlSafe(yaml, configPath), { apiBase, model }); + const out = yaml.dump(merged, { lineWidth: -1 }); + + if (dryRun) { + console.log("\n" + out); + printInfo(`[dry-run] → ${configPath}`); + } else { + mkdirSync(join(configPath, ".."), { recursive: true }); + writeFileSync(configPath, out, "utf8"); + printSuccess(`Wrote ${configPath}`); + } + printInfo("\nProvide the key + run (the key stays in the env, never the file):"); + console.log(buildAiderRecipe({ apiBase, model })); + return 0; +} + +export function registerSetupAider(program) { + program + .command("setup-aider") + .description("Configure Aider for OmniRoute: write ~/.aider.conf.yml + print the env recipe") + .option("--port ", "Local OmniRoute port (ignored when --remote is set)", "20128") + .option("--remote ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") + .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") + .option("--model ", "Model id (the openai/ prefix is added automatically)") + .option("--config-path ", ".aider.conf.yml path (default: ~/.aider.conf.yml)") + .option("--yes", "Non-interactive: do not prompt (requires --model)") + .option("--dry-run", "Print what would be written without touching the filesystem") + .action(async (opts) => { + const code = await runSetupAiderCommand(opts); + if (code !== 0) process.exit(code); + }); +} diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md index 21540436ac..01566f6382 100644 --- a/docs/guides/REMOTE-MODE.md +++ b/docs/guides/REMOTE-MODE.md @@ -155,6 +155,7 @@ context, or `--remote --api-key `): | Crush | `omniroute setup-crush` | `~/.config/crush/crush.json` — `openai-compat` provider, `base_url` **with** `/v1`, key via `$OMNIROUTE_API_KEY` | | Goose | `omniroute setup-goose` | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER=openai` + `OPENAI_HOST` **without** `/v1` + `GOOSE_MODEL`) + env recipe | | Qwen Code | `omniroute setup-qwen` | `~/.qwen/settings.json` — openai `modelProvider`, `baseUrl` **with** `/v1`, key via `envKey` (OMNIROUTE_API_KEY) | +| Aider | `omniroute setup-aider` | `~/.aider.conf.yml` (`openai-api-base` **without** `/v1` + `model: openai/`) + env recipe (`aider --message --yes`) | ```bash # OpenCode (openai-compatible provider, all catalog models, remote VPS) diff --git a/tests/unit/cli/setup-aider.test.ts b/tests/unit/cli/setup-aider.test.ts new file mode 100644 index 0000000000..6d2167b4c7 --- /dev/null +++ b/tests/unit/cli/setup-aider.test.ts @@ -0,0 +1,24 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveAiderTarget, buildAiderConfig, buildAiderRecipe } from "../../../bin/cli/commands/setup-aider.mjs"; + +test("resolveAiderTarget strips /v1 (LiteLLM appends it)", () => { + assert.equal(resolveAiderTarget({ remote: "http://vps:20128/v1/" }).apiBase, "http://vps:20128"); + assert.equal(resolveAiderTarget({ remote: "http://vps:20128" }).apiBase, "http://vps:20128"); +}); +test("resolveAiderTarget: explicit --api-key wins", () => { + assert.equal(resolveAiderTarget({ remote: "http://x:20128", apiKey: "sk-x" }).apiKey, "sk-x"); +}); +test("buildAiderConfig sets openai-api-base + openai/, preserves rest", () => { + const c = buildAiderConfig({ "auto-commits": false }, { apiBase: "http://vps:20128", model: "glm/glm-5.2" }); + assert.equal(c["openai-api-base"], "http://vps:20128"); + assert.equal(c.model, "openai/glm/glm-5.2"); + assert.equal(c["auto-commits"], false); +}); +test("buildAiderRecipe references the env key + headless command", () => { + const r = buildAiderRecipe({ apiBase: "http://vps:20128", model: "glm/glm-5.2" }); + assert.ok(r.includes("OPENAI_API_BASE=http://vps:20128")); + assert.ok(r.includes("OPENAI_API_KEY=$OMNIROUTE_API_KEY")); + assert.ok(r.includes("--model openai/glm/glm-5.2")); + assert.ok(r.includes("--message") && r.includes("--yes")); +});