Repository navigation
feat(cli): setup-goose — configure Goose for OmniRoute (config.yaml + env recipe) #4300
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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}`); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
Comment on lines
+123
to
+127
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Filesystem write operations (
Suggested change
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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")); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the server is offline or returns no models,
fetchModelIdswill return an empty array. In interactive mode (when--yesis not passed), the current implementation will skip prompting the user entirely and immediately fail with 'A model is required'. We should prompt the user for a model ID even if the automatic fetch returns no examples, allowing them to manually enter the model they wish to use.