diff --git a/CHANGELOG.md b/CHANGELOG.md index f1bb717..f03bc81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.9.6] - 2026-10-06 + +### Added + +- **`--baseUrl` and `--apiKey` flags for headless mode.** Both are optional and route the request through an OpenAI-compatible endpoint instead of the MatterAI gateway. `--baseUrl` alone is enough when the endpoint doesn't require auth. When `--baseUrl` is given without `--model` (or `MATTERAI_MODEL`), the model defaults to `gpt-4o`. These flags set `MATTERAI_LLM_BASE_URL` / `MATTERAI_LLM_API_KEY` — deliberately distinct from the existing `MATTERAI_BASE_URL` / `MATTERAI_API_KEY`, which override the gateway URL and auth token and would otherwise break backend calls (models list, `/usage`, auth). +- **Structured headless output with `--json`.** `orbcode -p "…" --json` prints exactly one JSON object to stdout and nothing else: `{ ok, model, result, usage: { inputTokens, outputTokens, cost, totalCost }, sessionId, error }`. The `model` field reports the model actually used (so callers can verify the requested model ran), `usage` accumulates token counts and cost across the whole run, and `error` is `null` on success. The "Session saved…" line stays on stderr, so stdout is pure JSON. +- **`--require-model` to fail fast on an unknown model.** Previously an unknown `--model` warned to stderr and silently ran the default model instead. With `--require-model` (or automatically in `--json` mode) the CLI now exits non-zero with a clear error instead of running a different model than requested. +- **`--output-file ` for long-task results.** The agent is instructed to write its final structured result to the given path via `file_write`; headless mode reads that file and puts its contents in the envelope's `result` (falling back to the completion/text if the file is absent). This makes structured output reliable even when the model's final chat message is truncated or mangled. +- **`--verbose` event stream to stderr.** Prints tool-start/tool-end lines to stderr for observability during long agent runs, without polluting stdout. + ## [6.9.5] - 2026-10-05 ### Fixed diff --git a/package-lock.json b/package-lock.json index cd8ffd8..d3c37ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@matterailab/orbcode", - "version": "6.9.5", + "version": "6.9.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@matterailab/orbcode", - "version": "6.9.3", + "version": "6.9.6", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^3.0.85", diff --git a/package.json b/package.json index f3a829c..b840535 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "6.9.5", + "version": "6.9.6", "description": "OrbCode CLI — agentic coding in your terminal, by MatterAI", "type": "module", "bin": { @@ -41,6 +41,7 @@ "test:readonly": "node --import tsx --test test/read-only-command.test.ts", "test:search": "node --import tsx --test test/search-files.test.ts", "test:rewind": "node --import tsx --test test/rewind.test.ts", + "test:headless": "node --import tsx --test test/headless.test.ts", "test:ui": "bun test test/ui-viewport.test.tsx test/session-picker.test.tsx test/rewind-picker.test.tsx", "prepublishOnly": "npm run typecheck && npm run build" }, diff --git a/src/headless.ts b/src/headless.ts index 4b2bf9b..2e03053 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -1,3 +1,6 @@ +import * as fs from "node:fs" +import * as path from "node:path" + import { AXON_MODELS, DEFAULT_MODEL_ID, @@ -13,6 +16,7 @@ import { isEidoProAxonModel, isLumenAxonModel, isValidAxonModel, + registerCustomModels, usesAiSdk, } from "./api/models.js" import { fetchProfile } from "./auth/auth.js" @@ -21,16 +25,64 @@ import { Agent } from "./core/agent.js" import type { AgentEvent } from "./core/events.js" import { McpManager } from "./mcp/manager.js" +export interface HeadlessOptions { + yolo: boolean + systemPromptOverride?: string + /** "json" emits a structured envelope; "text" (default) prints the final message. */ + outputMode?: "text" | "json" + /** When true, exit non-zero if the requested model is not registered. */ + requireModel?: boolean + /** Path to a file the agent should write its final structured result to. */ + outputFile?: string + /** When true, print tool-start/tool-end events to stderr for observability. */ + verbose?: boolean +} + +/** + * Whether a tool call is the --output-file write, which headless mode approves + * even without --yolo. Both paths are resolved so `result.json`, `./result.json` + * and the absolute path all match, while any other target stays denied. + */ +export function isOutputFileWrite(outputFile: string | undefined, toolName: string, detail: string): boolean { + if (!outputFile || toolName !== "file_write" || !detail) return false + return path.resolve(detail) === path.resolve(outputFile) +} + /** Non-interactive `orbcode -p "prompt"` mode: prints the final response to stdout. */ -export async function runHeadless( - prompt: string, - yolo: boolean, - systemPromptOverride?: string, -): Promise { +export async function runHeadless(prompt: string, options: HeadlessOptions): Promise { + const { yolo, systemPromptOverride, outputMode = "text", requireModel, outputFile, verbose } = options const settings = loadSettings() const token = getAuthToken(settings) - if (token) { + // --baseUrl / --apiKey: register a synthetic OpenAI-compatible model so the + // request goes through the AI SDK transport instead of the MatterAI gateway. + // Dedicated env names — MATTERAI_BASE_URL / MATTERAI_API_KEY already exist + // and override the gateway URL / auth token (see settings.ts), so reusing + // them would break backend calls (models list, /usage, auth). + const customBaseUrl = process.env.MATTERAI_LLM_BASE_URL + const customApiKey = process.env.MATTERAI_LLM_API_KEY + if (customBaseUrl) { + const modelId = process.env.MATTERAI_MODEL ?? "gpt-4o" + registerCustomModels([ + { + id: modelId, + name: modelId, + description: "OpenAI-compatible model via --baseUrl", + contextWindow: 200_000, + maxOutputTokens: 32_000, + supportsImages: false, + inputPrice: 0, + outputPrice: 0, + provider: "openai-compatible", + baseUrl: customBaseUrl, + apiKey: customApiKey, + }, + ]) + settings.model = modelId + settings.modelExplicit = true + } + + if (token && !customBaseUrl) { await fetchDynamicModels(token).catch(() => {}) } @@ -48,6 +100,14 @@ export async function runHeadless( if (preferred !== settings.model) settings.model = preferred } if (requestedModel && !isValidAxonModel(requestedModel)) { + if (requireModel || outputMode === "json") { + // In programmatic mode, silently falling back is worse than failing fast. + process.stderr.write( + `error: unknown model "${requestedModel}". ` + + `Add it under "customModels" in settings.json (with a "provider") to use it.\n`, + ) + process.exit(1) + } process.stderr.write( `warning: unknown model "${requestedModel}"; using "${settings.model}". ` + `Add it under "customModels" in settings.json (with a "provider") to use it.\n`, @@ -58,7 +118,7 @@ export async function runHeadless( // provider from the env (e.g. ANTHROPIC_API_KEY) or the model's `apiKey` — // so they don't need a MatterAI login. Only gate on the token when the // selected model actually goes through the MatterAI gateway. - if (!token && !usesAiSdk(getModel(settings.model))) { + if (!token && !usesAiSdk(getModel(settings.model)) && !customBaseUrl) { console.error("Not signed in. Run `orbcode login`, set MATTERAI_TOKEN, or put an apiKey in settings.json.") process.exit(1) } @@ -129,6 +189,12 @@ export async function runHeadless( let textBuffer = "" let lastText = "" let completionResult = "" + let usageInputTokens = 0 + let usageOutputTokens = 0 + let usageCost = 0 + let usageTotalCost = 0 + let errorMessage: string | null = null + const agent = new Agent({ cwd: process.cwd(), // May be empty for AI-SDK providers; AiSdkClient ignores it and uses the @@ -155,19 +221,41 @@ export async function runHeadless( case "completion": completionResult = event.result break + case "usage": + usageInputTokens += event.inputTokens + usageOutputTokens += event.outputTokens + usageCost += event.cost + // totalCost is already the agent's running session total. + usageTotalCost = event.totalCost + break case "system": // Hook messages go to stderr so stdout stays the final answer. process.stderr.write(`${event.isError ? "hook error" : "hook"}: ${event.message}\n`) break case "error": process.stderr.write(`error: ${event.message}\n`) + errorMessage = event.message exitCode = 1 break + case "tool-start": + if (verbose) { + process.stderr.write(`[tool] ${event.name}: ${event.summary}\n`) + } + break + case "tool-end": + if (verbose) { + const status = event.isError ? "✗" : "✓" + process.stderr.write(`[${status}] ${event.name}: ${event.summary}\n`) + } + break } }, // In headless mode there is nobody to ask; deny unless --yolo. + // Exception: writing the --output-file artifact is always allowed, + // so read-only analysis runs can still emit a structured result. requestApproval: async (request) => { if (yolo) return "yes" + if (isOutputFileWrite(outputFile, request.toolName, request.detail)) return "yes" process.stderr.write(`[denied] ${request.toolName}: ${request.summary} (pass --yolo to auto-approve)\n`) return "no" }, @@ -178,13 +266,59 @@ export async function runHeadless( }, }) - await agent.runTurn(prompt) + // When --output-file is set, instruct the agent to write its final result + // there via file_write. This is more reliable than parsing the chat message, + // which weaker models truncate or mangle. The path is resolved to an + // absolute path so the agent can't be confused by relative references. + const effectivePrompt = outputFile + ? `${prompt}\n\n[OrbCode] When you are done, write your final answer to the file "${path.resolve(outputFile)}" using the file_write tool. Put only the final result in that file — no commentary, no markdown fences.` + : prompt + + await agent.runTurn(effectivePrompt) await agent.endSession("other") await mcp.stop().catch(() => {}) - const finalContent = completionResult || lastText || textBuffer - if (finalContent) { + + // Resolve the final result: prefer the output-file artifact, then + // attempt_completion, then the last assistant text. + let finalContent = "" + if (outputFile) { + const resolvedPath = path.resolve(outputFile) + try { + finalContent = fs.readFileSync(resolvedPath, "utf8") + } catch { + // File not written; fall through to completion/text. + } + } + if (!finalContent) { + finalContent = completionResult || lastText || textBuffer + } + + if (outputMode === "json") { + const envelope = { + ok: exitCode === 0, + model: settings.model, + result: finalContent.trimEnd(), + usage: { + inputTokens: usageInputTokens, + outputTokens: usageOutputTokens, + cost: usageCost, + totalCost: usageTotalCost, + }, + sessionId: agent.taskId, + error: errorMessage, + } + process.stdout.write(JSON.stringify(envelope) + "\n") + } else if (finalContent) { process.stdout.write(finalContent.trimEnd() + "\n") } + process.stderr.write(`\nSession saved. To resume: orbcode --resume ${agent.taskId}\n`) + // process.exit() drops pending async writes (piped stdout is async on macOS), + // which would truncate `--json | jq`; exit only once both streams drain. + await Promise.all([flush(process.stdout), flush(process.stderr)]) process.exit(exitCode) } + +function flush(stream: NodeJS.WriteStream): Promise { + return new Promise((resolve) => stream.write("", () => resolve())) +} diff --git a/src/index.tsx b/src/index.tsx index bfe7387..87deb23 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -36,7 +36,13 @@ Usage: orbcode plugin list list plugins installed for this project orbcode -p "" run a single prompt non-interactively (prints only the final response) orbcode -p "…" --yolo non-interactive with auto-approved edits/commands + orbcode -p "…" --json non-interactive, prints a JSON envelope (model, usage, result) + orbcode -p "…" --require-model exit non-zero if the requested model is not registered + orbcode -p "…" --output-file read the final result from this file instead of chat text + orbcode -p "…" --verbose print tool-start/tool-end events to stderr orbcode --model use a specific model for this run + orbcode -p "..." --baseUrl headless: use an OpenAI-compatible endpoint + orbcode -p "..." --apiKey headless: API key for that endpoint orbcode --resume list previous sessions to resume orbcode --resume resume a previous session by id orbcode -s "" override the default system prompt (replaces it entirely; @@ -199,7 +205,29 @@ async function main(): Promise { console.error("Missing prompt after -p") process.exit(1) } - await runHeadless(prompt, args.includes("--yolo"), systemPromptOverride) + const jsonMode = args.includes("--json") + const requireModel = args.includes("--require-model") + const verbose = args.includes("--verbose") + const outputFile = takeFlagValue(args, "output-file") + + // --baseUrl / --apiKey: use an OpenAI-compatible endpoint instead of the + // MatterAI gateway. Headless-only: the TUI builds its agent from + // settings.baseUrl / getAuthToken(settings), and MATTERAI_BASE_URL / + // MATTERAI_API_KEY already feed those (gateway URL / auth token), so + // these flags use dedicated env names read only by runHeadless. + const baseUrl = takeFlagValue(args, "baseUrl") ?? takeFlagValue(args, "base-url") + if (baseUrl) process.env.MATTERAI_LLM_BASE_URL = baseUrl + const apiKey = takeFlagValue(args, "apiKey") ?? takeFlagValue(args, "api-key") + if (apiKey) process.env.MATTERAI_LLM_API_KEY = apiKey + + await runHeadless(prompt, { + yolo: args.includes("--yolo"), + systemPromptOverride, + outputMode: jsonMode ? "json" : "text", + requireModel, + outputFile, + verbose, + }) return } diff --git a/test/headless.test.ts b/test/headless.test.ts new file mode 100644 index 0000000..319e1b5 --- /dev/null +++ b/test/headless.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict" +import test from "node:test" +import * as path from "node:path" + +import { isOutputFileWrite } from "../src/headless.js" + +const outputFile = path.join("out", "result.json") +const absolute = path.resolve(outputFile) + +test("approves file_write to the output file however the path is spelled", () => { + assert.equal(isOutputFileWrite(outputFile, "file_write", absolute), true) + assert.equal(isOutputFileWrite(outputFile, "file_write", outputFile), true) + assert.equal(isOutputFileWrite(outputFile, "file_write", `./${outputFile}`), true) + assert.equal(isOutputFileWrite(absolute, "file_write", outputFile), true) +}) + +test("denies file_write to any other path", () => { + assert.equal(isOutputFileWrite(outputFile, "file_write", path.join("out", "other.json")), false) + // A substring/prefix match must not be enough. + assert.equal(isOutputFileWrite(outputFile, "file_write", `${absolute}.bak`), false) + assert.equal(isOutputFileWrite(outputFile, "file_write", path.join("out", "..", "result.json")), false) +}) + +test("denies tools other than file_write even on the output path", () => { + assert.equal(isOutputFileWrite(outputFile, "execute_command", absolute), false) + assert.equal(isOutputFileWrite(outputFile, "apply_diff", absolute), false) +}) + +test("denies everything when no output file is set or the path is empty", () => { + assert.equal(isOutputFileWrite(undefined, "file_write", absolute), false) + // An empty path would otherwise resolve to cwd. + assert.equal(isOutputFileWrite(outputFile, "file_write", ""), false) +})