#!/usr/bin/env node import { setupDb } from "../db/setup.ts"; setupDb(); import React from "react"; import path from "path"; import os from "os"; import fs from "fs/promises"; import chalk from "chalk"; import { Command } from "@commander-js/extra-typings"; import { fileExists } from "../fs-utils.ts"; import App from "../app.tsx"; import { readConfig, readAuthForModel, AUTOFIX_KEYS, APP_METADATA } from "../config.ts"; import { tokenCounts } from "../token-tracker.ts"; import { getMcpClient, connectMcpServer, shutdownMcpClients } from "../tools/tool-defs/mcp.ts"; import { FirstTimeSetup } from "../first-time-setup.tsx"; import { PreflightModelAuth, PreflightAutofixAuth } from "../preflight-auth.tsx"; import { Transport } from "../transports/transport-common.ts"; import { LocalTransport } from "../transports/local.ts"; import { DockerTransport, manageContainer } from "../transports/docker.ts"; import { readUpdates, markUpdatesSeen } from "../update-notifs/update-notifs.ts"; import { migrate } from "../db/migrate.ts"; import { run } from "../compilers/run.ts"; import type { ModelData } from "../compilers/run.ts"; import { loadInputHistory } from "../input-history/index.ts"; import { makeAutofixJson } from "../compilers/autofix.ts"; import { discoverSkills } from "../skills/skills.ts"; import { timeout } from "../signals.ts"; import { shutdownLspClients } from "../lsp/client.ts"; import { replaceDockerRunArgs, replaceOctoFlags, withOctoFlags } from "./cli-args.ts"; import type { ParsedCliArgs } from "./cli-args.ts"; import { deleteSession, listSessions, loadSession } from "../session-history/index.ts"; import type { LoadedSession, Session } from "../session-history/index.ts"; import { useAppStore } from "../state.ts"; import { FOREGROUND_COLOR, THEME_COLOR } from "../theme.ts"; import { SESSION_ID_HEADER, SESSION_PREVIEW_HEADER, SESSION_UPDATED_HEADER, sessionListTable, } from "../session-history/session-list.ts"; import { KeyboardProvider } from "../hooks/use-keyboard.ts"; import { render, type CreateRootOptions } from "paintcannon-react"; import { ToastProvider } from "../components/toast.tsx"; import { setOctoTitles } from "./titles.ts"; const __dirname = import.meta.dirname; const INTERACTIVE_RENDER_OPTIONS = { alternateScreen: true, captureMouse: true, captureCtrlC: true, } satisfies CreateRootOptions; function renderInteractive(element: React.ReactNode, options: { captureCtrlC: boolean }) { const root = render( {element} , { ...INTERACTIVE_RENDER_OPTIONS, captureCtrlC: options.captureCtrlC, }, ); root.container.style.position = "relative"; root.container.style.overflowX = "hidden"; root.container.style.color = FOREGROUND_COLOR; return root; } const CONFIG_STANDARD_DIR = path.join(os.homedir(), ".config/octofriend/"); const CONFIG_JSON5_FILE = path.join(CONFIG_STANDARD_DIR, "octofriend.json5"); const cli = withOctoFlags( new Command().description("If run with no subcommands, runs Octo interactively."), ) .option("--resume ", "Resume a previous Octo session") .argument("[docker-run-args...]", "Optional replacement args for `docker run` when resuming") .action(async (dockerRunArgs, opts) => { const resumeSessionId = opts.resume ?? null; if (resumeSessionId == null && dockerRunArgs.length > 0) { console.error("Use octo docker run to start a new session with Docker."); process.exitCode = 1; return; } let runConfig = null; if (resumeSessionId != null) { runConfig = await buildConfigForResuming(resumeSessionId, { config: opts.config, unchained: opts.unchained, dockerRunArgs: dockerRunArgs.length > 0 ? dockerRunArgs : undefined, }); if (runConfig == null) return; } else { runConfig = { loadedSession: null, transport: new LocalTransport(), parsedCliArgs: { kind: "local" as const, config: opts.config, unchained: opts.unchained, }, }; } await runMain(runConfig); }); const docker = cli.command("docker").description("Sandbox Octo inside Docker"); withOctoFlags(docker.command("connect")) .description("Sandbox Octo inside an already-running container") .argument("", "The Docker container") .action(async (target, opts) => { const transport = await DockerTransport.create({ type: "container", container: target, }); await runMain({ transport, loadedSession: null, parsedCliArgs: { kind: "docker-connect", target, config: opts.config, unchained: opts.unchained, }, }); }); withOctoFlags(docker.command("run")) .description( "Run a Docker image and sandbox Octo inside it, shutting it down when Octo shuts down", ) .argument("[args...]", "The args to pass to `docker run`") .action(async (args, opts) => { const transport = await DockerTransport.create({ type: "image", image: await manageContainer(args), }); await runMain({ transport, loadedSession: null, parsedCliArgs: { kind: "docker-run", dockerRunArgs: args, config: opts.config, unchained: opts.unchained, }, }); }); async function buildConfigForResuming( resumeSessionId: string, overrides: { config?: string; unchained?: boolean; dockerRunArgs?: string[]; }, ): Promise<{ loadedSession: LoadedSession; transport: Transport; parsedCliArgs: ParsedCliArgs; } | null> { const loadedSession = loadSession(resumeSessionId); if (loadedSession == null) { console.error(`No session found with ID ${resumeSessionId}.`); process.exit(1); } const storedCliArgs = loadedSession.session.metadata.cliArgs; let effectiveCliArgs = replaceOctoFlags(storedCliArgs, overrides); if (overrides.dockerRunArgs != null) { if (effectiveCliArgs.kind !== "docker-run") { console.error(` Cannot override Docker run args for session ${resumeSessionId}: that session was not initialized with \`octo docker run\`., `); process.exit(1); } else { effectiveCliArgs = replaceDockerRunArgs(effectiveCliArgs, overrides.dockerRunArgs); } } const shared = { parsedCliArgs: effectiveCliArgs, loadedSession, }; switch (effectiveCliArgs.kind) { case "local": return { ...shared, transport: new LocalTransport(), }; case "docker-connect": return { ...shared, transport: await DockerTransport.create({ type: "container", container: effectiveCliArgs.target, }), }; case "docker-run": return { ...shared, transport: await DockerTransport.create({ type: "image", image: await manageContainer(effectiveCliArgs.dockerRunArgs), }), }; } } async function runMain(opts: { parsedCliArgs: ParsedCliArgs; transport: Transport; loadedSession?: LoadedSession | null; }) { const restoreTitles = setOctoTitles(); let session: Session; if (opts.loadedSession != null) { session = { ...opts.loadedSession.session, metadata: { ...opts.loadedSession.session.metadata, cliArgs: opts.parsedCliArgs, }, }; useAppStore.getState().hydrateSession(opts.loadedSession.history); } else { session = useAppStore.getState().startNewSession(opts.transport.cwd, opts.parsedCliArgs); } try { let { config, configPath } = await loadConfig(opts.parsedCliArgs.config); // Connect to all MCP servers on boot if (config.mcpServers && Object.keys(config.mcpServers).length > 0) { for (const server of Object.keys(config.mcpServers)) { try { console.log("Connecting to", server, "MCP server..."); // Run the basic connection setup with logging enabled, so that first-time setup gets logged const client = await connectMcpServer(server, config, true); if (!client.success) throw new Error(client.error); await client.data.close(); // Then run the cache setup codepath, so future results use a cached client with logging off const cachedClient = await getMcpClient(server, config); if (!cachedClient.success) throw new Error(cachedClient.error); console.log("Connected to", server, "MCP server"); } catch (error) { console.warn( `Warning: Failed to connect to "${server}" MCP server: ${error instanceof Error ? error.message : String(error)}`, ); console.warn("Octo will continue without this MCP server."); } } console.log("MCP server initialization complete."); } const skills = await discoverSkills(opts.transport, timeout(5000), config); const cwd = opts.transport.cwd; const { waitUntilExit } = renderInteractive( s.name)} config={config} configPath={configPath} cwd={cwd} metadata={APP_METADATA} unchained={!!opts.parsedCliArgs.unchained} transport={opts.transport} session={session} onSessionChange={nextSession => { session = nextSession; }} updates={await readUpdates()} inputHistory={await loadInputHistory()} />, { captureCtrlC: true }, ); await waitUntilExit(); const { history } = useAppStore.getState(); if (history.some(item => item.type === "llm-ir")) { const resumeCommand = chalk.hex(THEME_COLOR)(`octo --resume ${session.metadata.sessionId}`); console.log(`\nResume this session with ${resumeCommand}`); } console.log("\nApprox. tokens used:"); if (Object.keys(tokenCounts()).length === 0) { console.log("0"); } else { for (const [model, count] of Object.entries(tokenCounts())) { const input = count.input.toLocaleString(); const output = count.output.toLocaleString(); console.log(`${model}: ${input} input, ${output} output`); } } } finally { await shutdownLspClients(); await shutdownMcpClients(); restoreTitles(); await opts.transport.close(); } } cli .command("version") .description("Prints the current version") .action(async () => { console.log(APP_METADATA.version); }); cli .command("init") .description("Create a fresh config file for Octo") .action(() => { renderInteractive(, { captureCtrlC: false, }); }); cli .command("changelog") .description("List the changelog") .action(async () => { const changelog = await fs.readFile(path.join(__dirname, "../../../CHANGELOG.md"), "utf8"); console.log(changelog); }); cli .command("list") .description("List all models you've configured with Octo") .action(async () => { const { config } = await loadConfigWithoutReauth(); console.log(config.models.map(m => m.nickname).join("\n")); }); const sessionCommand = cli.command("session").description("Manage Octo sessions"); sessionCommand .command("list") .description("List sessions for the current working directory") .action(() => { const sessions = listSessions(process.cwd()); if (sessions.length === 0) return; const { rows, sessionIdWidth, previewWidth } = sessionListTable(sessions); const octoTheme = chalk.hex(THEME_COLOR); console.log(`Resume a session with: ${octoTheme("octo --resume ")}\n`); console.log( `${SESSION_ID_HEADER.padEnd(sessionIdWidth)} ${SESSION_PREVIEW_HEADER.padEnd(previewWidth)} ${SESSION_UPDATED_HEADER}`, ); console.log( `${"─".repeat(sessionIdWidth)} ${"─".repeat(previewWidth)} ${"─".repeat(SESSION_UPDATED_HEADER.length)}`, ); for (const row of rows) { console.log( `${octoTheme(row.sessionId.padEnd(sessionIdWidth))} ${chalk.dim((row.preview ?? "").padEnd(previewWidth))} ${chalk.white(row.updatedAtText)}`, ); } }); sessionCommand .command("delete") .description("Delete a session") .argument("", "The session ID to delete") .action(sessionId => { if (!deleteSession(sessionId)) { console.error(`No session found with ID ${sessionId}.`); process.exitCode = 1; return; } console.log(`Deleted session ${sessionId}.`); }); const bench = cli.command("bench"); bench .command("tps") .description("Benchmark tokens/sec from your API provider") .option( "--model ", "The nickname you gave for the model you want to use. If unspecified, uses your default model", ) .option( "--prompt ", "Custom prompt to benchmark with. If omitted, uses the default prompt.", ) .option("--concurrency ", "Concurrent requests to make. If omitted, defaults to 1") .action(async opts => { const { config } = await loadConfigWithoutReauth(); const transport = new LocalTransport(); const model = opts.model ? config.models.find(m => m.nickname === opts.model) : config.models[0]; if (model == null) { console.error(`No model with the nickname ${opts.model} found. Did you add it to Octo?`); console.error("The available models are:"); console.error("- " + config.models.map(m => m.nickname).join("\n- ")); process.exit(1); } const concurrency = Math.max(1, parseInt(opts.concurrency ?? "1", 10)); let modelData: ModelData; if (model.type === "codex") { const authResult = await readAuthForModel(model, config); if (!authResult.ok) { console.error(authResult.error.message); process.exit(1); } modelData = { type: "codex", auth: authResult.auth, model, }; } else { const authResult = await readAuthForModel(model, config); if (!authResult.ok) { console.error(authResult.error.message); process.exit(1); } modelData = { type: "api", auth: authResult.auth, model, }; } const autofixJson = makeAutofixJson(config); console.log( `Benchmarking ${model.nickname} with ${concurrency} concurrent request${concurrency > 1 ? "s" : ""}`, ); const abortController = new AbortController(); const timer = setInterval(() => { console.log("Still working..."); }, 5000); type SuccessfulBenchmark = { tokens: number; ttft: number; tokenElapsed: number; interTokenLatencies: number[]; success: true; }; type FailedBenchmark = { success: false; error: string; }; type BenchmarkResult = SuccessfulBenchmark | FailedBenchmark; async function runSingleBenchmark(): Promise { const start = new Date(); let firstToken: Date | null = null; const tokenTimestamps: Date[] = []; const result = await run({ modelData, autofixJson, messages: [ { role: "user", content: [ { type: "text", content: opts.prompt ?? "Write me a short story about a frog going to the moon. Do not use ANY tools.", }, ], }, ], handlers: { onTokens: () => { const now = new Date(); tokenTimestamps.push(now); if (firstToken == null) firstToken = now; }, onAutofixJson: () => {}, }, abortSignal: abortController.signal, transport, }); if (!result.success) { return { success: false, error: result.error.type === "auth-error" ? result.error.authError : result.error.requestError, }; } const end = new Date(); if (firstToken == null) { return { success: false, error: "No tokens received", }; } const ttft = (firstToken as Date).getTime() - start.getTime(); const tokenElapsed = end.getTime() - (firstToken as Date).getTime(); const tokens = result.data.usage.output; const interTokenLatencies: number[] = []; for (let i = 1; i < tokenTimestamps.length; i++) { const latency = tokenTimestamps[i].getTime() - tokenTimestamps[i - 1].getTime(); interTokenLatencies.push(latency); } return { tokens, ttft, tokenElapsed, interTokenLatencies, success: true, }; } const benchmarkStart = new Date(); const results = await Promise.all( Array.from( { length: concurrency, }, () => runSingleBenchmark(), ), ); const benchmarkEnd = new Date(); clearInterval(timer); const failures = results.filter((r): r is FailedBenchmark => !r.success); if (failures.length > 0) { console.error(`\n${failures.length} request(s) failed:`); for (const f of failures) { console.error(` - ${f.error}`); } if (failures.length === concurrency) { process.exit(1); } } const successes = results.filter((r): r is SuccessfulBenchmark => r.success); if (successes.length === 0) { console.log("No successful requests"); process.exit(1); } const totalTokens = successes.reduce((sum, r) => sum + r.tokens, 0); const avgTokens = totalTokens / successes.length; const avgTtft = successes.reduce((sum, r) => sum + r.ttft, 0) / successes.length; const allInterTokenLatencies = successes.flatMap(r => r.interTokenLatencies); const avgTokenElapsed = successes.reduce((sum, r) => sum + r.tokenElapsed, 0) / successes.length; const totalTime = (benchmarkEnd.getTime() - benchmarkStart.getTime()) / 1000; const tps = totalTokens / totalTime; console.log(`\n Successful requests: ${successes.length}/${concurrency} Total tokens: ${totalTokens} Avg tokens per request: ${avgTokens.toFixed(2)} Total time: ${totalTime.toFixed(2)}s Avg time to first token: ${(avgTtft / 1000).toFixed(3)}s `); if (allInterTokenLatencies.length > 0) { let minLatency = allInterTokenLatencies[0]; let maxLatency = allInterTokenLatencies[0]; let total = 0; for (const latency of allInterTokenLatencies) { if (minLatency > latency) minLatency = latency; if (maxLatency < latency) maxLatency = latency; total += latency; } const avgLatency = total / allInterTokenLatencies.length; console.log(`Inter-token latencies (${allInterTokenLatencies.length} total): Min: ${minLatency}ms Max: ${maxLatency}ms Avg: ${avgLatency.toFixed(2)}ms Avg stream time per request: ${(avgTokenElapsed / 1000).toFixed(3)}s `); } console.log(`Tok/sec output (overall): ${tps.toFixed(2)} Tok/sec output (per-request avg): ${successes.map(r => r.tokens / (r.tokenElapsed / 1000)).reduce((a, b) => a + b, 0) / successes.length} `); }); cli .command("prompt") .description("Sends a prompt to a model") .option("--system ", "An optional system prompt") .option( "--model ", "The nickname you gave for the model you want to use. If unspecified, uses your default model", ) .argument("", "The prompt you want to send to this model") .action(async (prompt, opts) => { const { config } = await loadConfig(); const transport = new LocalTransport(); const model = opts.model ? config.models.find(m => m.nickname === opts.model) : config.models[0]; if (model == null) { console.error(`No model with the nickname ${opts.model} found. Did you add it to Octo?`); console.error("The available models are:"); console.error("- " + config.models.map(m => m.nickname).join("\n- ")); process.exit(1); } let modelData: ModelData; if (model.type === "codex") { const authResult = await readAuthForModel(model, config); if (!authResult.ok) { console.error(`${model.nickname} doesn't have auth set up.`); const error = authResult.error; if (error.type === "missing") { console.error(`${error.message}`); } else if (error.type === "command_failed") { console.error(`Command execution failed: ${error.message}`); if (error.exitCode != null) { console.error(`Exit code: ${error.exitCode}`); } if (error.stderr) { console.error(`stderr: ${error.stderr}`); } } else if (error.type === "invalid") { console.error(`Invalid auth configuration: ${error.message}`); } process.exit(1); } modelData = { type: "codex", auth: authResult.auth, model, }; } else { const authResult = await readAuthForModel(model, config); if (!authResult.ok) { console.error(`${model.nickname} doesn't have auth set up.`); const error = authResult.error; if (error.type === "missing") { console.error(`${error.message}`); if (model.auth?.type === "env") { console.error(`Hint: do you need to re-source your .bash_profile or .zshrc?`); } } else if (error.type === "command_failed") { console.error(`Command execution failed: ${error.message}`); if (error.exitCode != null) { console.error(`Exit code: ${error.exitCode}`); } if (error.stderr) { console.error(`stderr: ${error.stderr}`); } } else if (error.type === "invalid") { console.error(`Invalid auth configuration: ${error.message}`); } process.exit(1); } modelData = { type: "api", auth: authResult.auth, model, }; } const messages = [ { role: "user" as const, content: [ { type: "text" as const, content: prompt, }, ], }, ]; let systemPrompt: undefined | (() => Promise) = undefined; if (opts.system) { const sys = opts.system; systemPrompt = async () => sys; } const autofixJson = makeAutofixJson(config); const abortController = new AbortController(); let seenReasoning = false; let seenContent = false; const result = await run({ modelData, systemPrompt, messages, autofixJson, handlers: { onTokens: (chunk, type) => { if (type === "reasoning") seenReasoning = true; if (seenReasoning && type === "content" && !seenContent) { seenContent = true; process.stderr.write("\n\n"); } if (type === "reasoning") process.stderr.write(chunk); else process.stdout.write(chunk); }, onAutofixJson: () => {}, }, abortSignal: abortController.signal, transport, }); if (!result.success) { if (result.error.type === "auth-error") { console.error(result.error.authError); } else { console.error(result.error.requestError); console.error(`cURL: ${result.error.curl}`); } process.exit(1); } process.stdout.write("\n"); }); async function loadConfig(path?: string) { let { config, configPath } = await loadConfigWithoutReauth(path); let defaultModel = config.models[0]; if (!(await readAuthForModel(defaultModel, config)).ok) { const { waitUntilExit } = renderInteractive( , { captureCtrlC: false }, ); await waitUntilExit(); const reloaded = await loadConfigWithoutReauth(path); config = reloaded.config; configPath = reloaded.configPath; defaultModel = config.models[0]; if (!(await readAuthForModel(defaultModel, config)).ok) process.exit(1); } for (const key of AUTOFIX_KEYS) { let autofixModel = config[key]; if (autofixModel) { if (!(await readAuthForModel(autofixModel, config)).ok) { const { waitUntilExit } = renderInteractive( , { captureCtrlC: false }, ); await waitUntilExit(); const reloaded = await loadConfigWithoutReauth(path); config = reloaded.config; configPath = reloaded.configPath; autofixModel = config[key]; if (autofixModel && !(await readAuthForModel(autofixModel, config)).ok) process.exit(1); } } } return { config, configPath, }; } async function loadConfigWithoutReauth(configPath?: string) { if (configPath) return { configPath, config: await readConfig(configPath), }; if (await fileExists(CONFIG_JSON5_FILE)) { return { configPath: CONFIG_JSON5_FILE, config: await readConfig(CONFIG_JSON5_FILE), }; } // This is first-time setup; mark all updates as seen to avoid showing an update message on boot await markUpdatesSeen(); const { waitUntilExit } = renderInteractive(, { captureCtrlC: false, }); await waitUntilExit(); if (await fileExists(CONFIG_JSON5_FILE)) { return { configPath: CONFIG_JSON5_FILE, config: await readConfig(CONFIG_JSON5_FILE), }; } process.exit(1); } migrate().then(() => { cli.parse(); });