mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
feat(cli): setup-goose — configure Goose for OmniRoute (config.yaml + env recipe) (#4300)
CLI #10 of the series. `omniroute setup-goose` writes Goose's file-based ~/.config/goose/config.yaml (GOOSE_PROVIDER=openai, GOOSE_MODEL=<model>, OPENAI_HOST=<root, NO /v1 — Goose appends the path itself>), merges to preserve existing keys, and prints the guaranteed env-var recipe (the key lives in the env / OS keyring, never the config). Remote-aware (--remote/--api-key → context → localhost); model via --model or interactive pick. Researched against block/goose: provider openai + OPENAI_HOST base (no /v1). Goose's wire (/v1/chat/completions) already validated → "OK". Tests: resolveGooseTarget (/v1 strip, key), buildGooseConfig (provider/model/host + preserve), buildGooseEnvRecipe (env-ref key). 4 unit tests; cli-i18n green.
This commit is contained in:
committed by
GitHub
parent
26dd5d775c
commit
25f9dac9e9
@@ -66,6 +66,7 @@ import { registerSetupContinue } from "./setup-continue.mjs";
|
||||
import { registerSetupCursor } from "./setup-cursor.mjs";
|
||||
import { registerSetupRoo } from "./setup-roo.mjs";
|
||||
import { registerSetupCrush } from "./setup-crush.mjs";
|
||||
import { registerSetupGoose } from "./setup-goose.mjs";
|
||||
import { registerConnect } from "./connect.mjs";
|
||||
import { registerTokens } from "./tokens.mjs";
|
||||
import { registerConfigure } from "./configure.mjs";
|
||||
@@ -142,6 +143,7 @@ export function registerCommands(program) {
|
||||
registerSetupCursor(program);
|
||||
registerSetupRoo(program);
|
||||
registerSetupCrush(program);
|
||||
registerSetupGoose(program);
|
||||
registerConnect(program);
|
||||
registerTokens(program);
|
||||
registerConfigure(program);
|
||||
|
||||
150
bin/cli/commands/setup-goose.mjs
Normal file
150
bin/cli/commands/setup-goose.mjs
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* omniroute setup-goose — configure Goose (block/goose) for OmniRoute.
|
||||
*
|
||||
* Goose is a terminal AI agent with a file-based config at
|
||||
* ~/.config/goose/config.yaml and env-var overrides. For a custom OpenAI-
|
||||
* compatible endpoint it uses provider `openai` with OPENAI_HOST = the ROOT url
|
||||
* (NO /v1 — Goose appends the path itself). This writes the config.yaml keys and
|
||||
* prints the guaranteed env-var recipe (the API key lives in the OS keyring / env,
|
||||
* never the config file).
|
||||
*/
|
||||
|
||||
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_HOST (ROOT, no /v1 — Goose appends) + apiKey. */
|
||||
export function resolveGooseTarget(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 { host: root, apiKey };
|
||||
}
|
||||
|
||||
/** Merge Goose's provider/model keys into config.yaml object (preserve the rest). */
|
||||
export function buildGooseConfig(existing, { host, model }) {
|
||||
const cfg = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
cfg.GOOSE_PROVIDER = "openai";
|
||||
cfg.GOOSE_MODEL = model;
|
||||
cfg.OPENAI_HOST = host; // ROOT — Goose appends /v1/chat/completions
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** The guaranteed env-var recipe (pure → testable). */
|
||||
export function buildGooseEnvRecipe({ host, model }) {
|
||||
return [
|
||||
"export GOOSE_PROVIDER=openai",
|
||||
`export OPENAI_HOST=${host}`,
|
||||
"export OPENAI_API_KEY=$OMNIROUTE_API_KEY",
|
||||
`export GOOSE_MODEL=${model}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function readYamlSafe(yaml, path) {
|
||||
try {
|
||||
if (existsSync(path)) return yaml.load(readFileSync(path, "utf8")) || {};
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(host, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${host}/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 runSetupGooseCommand(opts = {}) {
|
||||
const { host, apiKey } = resolveGooseTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml");
|
||||
|
||||
printHeading("OmniRoute → Goose (openai-compatible)");
|
||||
printInfo(`OPENAI_HOST: ${host} (no /v1 — Goose appends it)`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(host, 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 Goose");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id>.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const yaml = await import("js-yaml");
|
||||
const merged = buildGooseConfig(readYamlSafe(yaml, configPath), { host, 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 (Goose reads it from the env / OS keyring):");
|
||||
console.log(buildGooseEnvRecipe({ host, model }));
|
||||
printInfo("Then run: goose session (or: goose run -t \"reply OK\")");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupGoose(program) {
|
||||
program
|
||||
.command("setup-goose")
|
||||
.description("Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + print the env recipe")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Goose (required unless picked interactively)")
|
||||
.option("--config-path <path>", "config.yaml path (default: ~/.config/goose/config.yaml)")
|
||||
.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 runSetupGooseCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
@@ -153,6 +153,7 @@ context, or `--remote <url> --api-key <key>`):
|
||||
| Cursor | `omniroute setup-cursor` | prints the in-app steps (Settings → Models → Override OpenAI Base URL **with** `/v1` + key + model). Cursor config is opaque SQLite — chat panel only |
|
||||
| Roo Code | `omniroute setup-roo` | writes a Roo import JSON (`~/.omniroute/roo-settings.json`) + sets `roo-cline.autoImportSettingsPath` + prints UI steps (OpenAI-compatible, Base URL **with** `/v1`) |
|
||||
| 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 |
|
||||
|
||||
```bash
|
||||
# OpenCode (openai-compatible provider, all catalog models, remote VPS)
|
||||
|
||||
24
tests/unit/cli/setup-goose.test.ts
Normal file
24
tests/unit/cli/setup-goose.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveGooseTarget, buildGooseConfig, buildGooseEnvRecipe } from "../../../bin/cli/commands/setup-goose.mjs";
|
||||
|
||||
test("resolveGooseTarget strips /v1 (Goose appends it)", () => {
|
||||
assert.equal(resolveGooseTarget({ remote: "http://vps:20128/v1/" }).host, "http://vps:20128");
|
||||
assert.equal(resolveGooseTarget({ remote: "http://vps:20128" }).host, "http://vps:20128");
|
||||
});
|
||||
test("resolveGooseTarget: explicit --api-key wins", () => {
|
||||
assert.equal(resolveGooseTarget({ remote: "http://x:20128", apiKey: "sk-x" }).apiKey, "sk-x");
|
||||
});
|
||||
test("buildGooseConfig sets GOOSE_PROVIDER/MODEL + OPENAI_HOST (root), preserves rest", () => {
|
||||
const c = buildGooseConfig({ GOOSE_MODE: "auto" }, { host: "http://vps:20128", model: "glm/glm-5.2" });
|
||||
assert.equal(c.GOOSE_PROVIDER, "openai");
|
||||
assert.equal(c.GOOSE_MODEL, "glm/glm-5.2");
|
||||
assert.equal(c.OPENAI_HOST, "http://vps:20128");
|
||||
assert.equal(c.GOOSE_MODE, "auto");
|
||||
});
|
||||
test("buildGooseEnvRecipe references the env key (secret off disk)", () => {
|
||||
const r = buildGooseEnvRecipe({ host: "http://vps:20128", model: "m" });
|
||||
assert.ok(r.includes("OPENAI_HOST=http://vps:20128"));
|
||||
assert.ok(r.includes("OPENAI_API_KEY=$OMNIROUTE_API_KEY"));
|
||||
assert.ok(r.includes("GOOSE_PROVIDER=openai"));
|
||||
});
|
||||
Reference in New Issue
Block a user