#!/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 { render } from "ink"; import { Command } from "@commander-js/extra-typings"; import { fileExists } from "./fs-utils.ts"; import App from "./app.tsx"; import { readConfig, readMetadata, readKeyForModel, AUTOFIX_KEYS } from "./config.ts"; import { tokenCounts } from "./token-tracker.ts"; import { getMcpClient, connectMcpServer, shutdownMcpClients } from "./tools/tool-defs/mcp.ts"; import OpenAI from "openai"; import { LlmMessage } from "./compilers/standard.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 { loadInputHistory } from "./input-history/index.ts"; const __dirname = import.meta.dirname; const CONFIG_STANDARD_DIR = path.join(os.homedir(), ".config/laisscodex/"); const CONFIG_JSON5_FILE = path.join(CONFIG_STANDARD_DIR, "laisscodex.json5") const cli = new Command() .description("If run with no subcommands, runs LaissCodex interactively.") .option("--config ") .option("--unchained", "Skips confirmation for all tools, running them immediately. Dangerous.") .option( "--connect ", "Connect to a Docker container. For example, laisscodex --connect docker:some-container-name" ).action(async (opts) => { const transport = new LocalTransport(); try { await runMain({ config: opts.config, unchained: opts.unchained, transport, }); } finally { await transport.close(); } }); const docker = cli.command("docker").description("Sandbox LaissCodex inside Docker"); docker.command("connect") .description("Sandbox LaissCodex inside an already-running container") .option("--config ") .option("--unchained", "Skips confirmation for all tools, running them immediately. Dangerous.") .argument( "", "The Docker container" ).action(async (target, opts) => { const transport = new DockerTransport({ type: "container", container: target }); try { await runMain({ config: opts.config, unchained: opts.unchained, transport, }); } finally { await transport.close(); } }); docker.command("run") .description("Run a Docker image and sandbox LaissCodex inside it, shutting it down when LaissCodex shuts down") .option("--config ") .option("--unchained", "Skips confirmation for all tools, running them immediately. Dangerous.") .argument( "[args...]", "The args to pass to `docker run`" ).action(async (args, opts) => { const transport = new DockerTransport({ type: "image", image: await manageContainer(args), }); try { await runMain({ config: opts.config, unchained: opts.unchained, transport, }); } finally { await transport.close(); } }); async function runMain(opts: { config?: string, unchained?: boolean, transport: Transport, }) { try { const metadata = await readMetadata(); let { config, configPath } = await loadConfig(opts.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)) { 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); await client.close(); // Then run the cache setup codepath, so future results use a cached client with logging off await getMcpClient(server, config); } console.log("All MCP servers connected."); } const { waitUntilExit } = render( , { exitOnCtrlC: false, } ); await waitUntilExit(); 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 shutdownMcpClients(); } } cli.command("version") .description("Prints the current version") .action(async () => { const metadata = await readMetadata(); console.log(metadata.version); }); cli.command("init") .description("Create a fresh config file for LaissCodex") .action(() => { render( ); }); 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 LaissCodex") .action(async () => { const { config } = await loadConfigWithoutReauth(); console.log(config.models.map(m => m.nickname).join("\n")); }); 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.") .action(async (opts) => { const { config } = await loadConfigWithoutReauth(); 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 LaissCodex?`); console.error("The available models are:"); console.error("- " + config.models.map(m => m.nickname).join("\n- ")); process.exit(1); } console.log("Benchmarking", model.nickname); const abortController = new AbortController(); const timer = setInterval(() => { console.log("Still working..."); }, 5000); const start = new Date(); let firstToken: Date | null = null; const tokenTimestamps: Date[] = []; const result = await run({ config, skipSystemPrompt: true, modelOverride: model.nickname, messages: [ { role: "user", content: opts.prompt ?? "Write me a short story about a frog going to the moon. Do not use ANY tools.", } ], onTokens: () => { const now = new Date(); tokenTimestamps.push(now); if(firstToken == null) firstToken = now; }, onAutofixJson: () => {}, abortSignal: abortController.signal, transport: new LocalTransport(), }); if (!result.success) { console.error(result.requestError); console.error(`cURL: ${result.curl}`) process.exit(1); } clearInterval(timer); const end = new Date(); const first: null | Date = firstToken as null | Date; if(first == null) { console.log("No tokens sent"); return; } const ttft = first.getTime() - start.getTime(); const tokenElapsed = end.getTime() - first.getTime(); const firstResult = result.output[0]; if(firstResult.role !== "assistant") throw new Error("No assistant response"); const tokens = firstResult.outputTokens; const seconds = tokenElapsed/1000; // Calculate inter-token latencies const interTokenLatencies: number[] = []; for(let i = 1; i < tokenTimestamps.length; i++) { const latency = tokenTimestamps[i].getTime() - tokenTimestamps[i-1].getTime(); interTokenLatencies.push(latency); } const minLatency = Math.min(...interTokenLatencies); const maxLatency = Math.max(...interTokenLatencies); const avgLatency = interTokenLatencies.reduce((a, b) => a + b, 0) / interTokenLatencies.length; console.log(`\n Tokens: ${tokens} Time: ${seconds}s Time to first token: ${ttft / 1000}s Inter-token latencies: Min: ${minLatency}ms Max: ${maxLatency}ms Avg: ${avgLatency.toFixed(2)}ms Tok/sec output: ${tokens/seconds} `); }); 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 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 LaissCodex?`); console.error("The available models are:"); console.error("- " + config.models.map(m => m.nickname).join("\n- ")); process.exit(1); } const apiKey = await readKeyForModel(model, config); if(apiKey == null) { console.error(`${model.nickname} doesn't have an API key set up.`); if(model.apiEnvVar) { console.error(`It was set to use the ${model.apiEnvVar} env var, but that env var doesn't exist in the current shell. Hint: do you need to re-source your .bash_profile or .zshrc?`); } process.exit(1); } const client = new OpenAI({ apiKey, baseURL: model.baseUrl, }); const messages: LlmMessage[] = []; if(opts.system) { messages.push({ role: "system", content: opts.system, }); } messages.push({ role: "user", content: prompt, }); const response = await client.chat.completions.create({ model: model.model, messages, stream: true, }); for await(const chunk of response) { const content = chunk.choices[0].delta?.content; if(content) process.stdout.write(content); } process.stdout.write("\n"); }); async function loadConfig(path?: string) { let { config, configPath } = await loadConfigWithoutReauth(path); let defaultModel = config.models[0]; if(!await readKeyForModel(defaultModel, config)) { const { waitUntilExit } = render( ); await waitUntilExit(); const reloaded = await loadConfigWithoutReauth(path); config = reloaded.config; configPath = reloaded.configPath; defaultModel = config.models[0]; if(!await readKeyForModel(defaultModel, config)) process.exit(1); } for(const key of AUTOFIX_KEYS) { let autofixModel = config[key]; if(autofixModel) { if(!await readKeyForModel(autofixModel, config)) { const { waitUntilExit } = render( ); await waitUntilExit(); const reloaded = await loadConfigWithoutReauth(path); config = reloaded.config; configPath = reloaded.configPath; autofixModel = config[key]; if(autofixModel && !await readKeyForModel(autofixModel, config)) 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 } = render( ); 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(); });