import { execa } from "execa"; import { existsSync, mkdirSync } from "node:fs"; import path from "node:path"; import { Command } from "commander"; import pkg from "../package.json" with { type: "json" }; import { makeClient } from "./backend/client.js"; import { ConfigError, resolveBackendConfig, resolveModel } from "./config/config.js"; import { configFilePath, loadStoredConfig, saveStoredConfig, type StoredConfig } from "./config/store.js"; import { loadMergedHooks, userHooksFilePath } from "./hooks/config.js"; import { loadMergedServers, removeUserServer, saveUserServer, userMcpFilePath } from "./mcp/config.js"; import { isHttpServerConfig } from "./mcp/types.js"; import { deleteSession, listSessions, loadSession, mostRecentSessionId, sessionsDir } from "./persistence/sessionStore.js"; import { addInstalledPlugin, loadInstalledPlugins, pluginsDir, removeInstalledPlugin } from "./plugins/config.js"; import { loadPlugin } from "./plugins/loader.js"; import { runInkApp } from "./ui/ink/index.js"; const program = new Command(); function addBackendOptions(cmd: Command): Command { return cmd .option("-b, --backend ", "backend to use: ollama or lmstudio") .option("--base-url ", "override the backend's base URL"); } program .name("locode") .description("Agentic coding CLI for local models via Ollama and LM Studio") .version(pkg.version); addBackendOptions(program) .option("-m, --model ", "model name as known to the backend") .option( "--tool-mode ", "native | fallback | auto — auto defaults to native, probing only on /model and /backend", "auto", ) .option("-c, --continue", "resume the most recently saved conversation") .option("-r, --resume [id]", "resume a saved conversation by id, or pick interactively if no id is given") .action(async (opts) => { try { const { baseURL } = resolveBackendConfig(opts); // Only an explicit --model flag skips the picker; a saved/env default is offered as a suggestion instead. const model: string | undefined = opts.model; const suggestedModel = model ? undefined : resolveModel(undefined); const toolModeOverride = opts.toolMode === "native" || opts.toolMode === "fallback" ? opts.toolMode : undefined; let resumeSessionId: string | undefined; let interactiveResume = false; if (opts.continue) { resumeSessionId = mostRecentSessionId(); if (!resumeSessionId) { console.error("No saved sessions found."); process.exit(1); } } else if (opts.resume !== undefined) { if (typeof opts.resume === "string" && opts.resume.length > 0) { resumeSessionId = opts.resume; } else { interactiveResume = true; } } await runInkApp({ baseURL, model, suggestedModel, cwd: process.cwd(), toolModeOverride, resumeSessionId, interactiveResume, }); } catch (err) { if (err instanceof ConfigError) { console.error(err.message); process.exit(1); } console.error((err as Error).message); process.exit(1); } }); addBackendOptions(program.command("models")) .description("List models available from the configured backend") .action(async (opts) => { try { const { baseURL } = resolveBackendConfig(opts); const client = makeClient({ baseURL, model: "" }); const list = await client.models.list(); for (const m of list.data) { console.log(m.id); } } catch (err) { if (err instanceof ConfigError) { console.error(err.message); process.exit(1); } console.error(`Failed to list models: ${(err as Error).message}`); process.exit(1); } }); const configCmd = program.command("config").description("Manage persisted config"); configCmd .command("get [key]") .description("Print the whole config, or one key (backend, model, baseUrl)") .action((key?: string) => { const stored = loadStoredConfig(); if (!key) { console.log(JSON.stringify(stored, null, 2)); return; } console.log(stored[key as keyof StoredConfig] ?? "(not set)"); }); configCmd .command("set ") .description( "Persist a config value (backend, model, baseUrl, contextWindow, maxOutputTokens, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs, maxRetries, lspServers)", ) .action((key: string, value: string) => { if ( key !== "backend" && key !== "model" && key !== "baseUrl" && key !== "contextWindow" && key !== "maxOutputTokens" && key !== "maxIterations" && key !== "autoCompactThreshold" && key !== "requestTimeoutMs" && key !== "subagentTimeoutMs" && key !== "maxRetries" && key !== "lspServers" ) { console.error( `Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxOutputTokens, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs, maxRetries, lspServers`, ); process.exit(1); } const stored = loadStoredConfig(); if (key === "lspServers") { // lspServers is a JSON object: { "": { "command": "...", "args": [...], "extensions": [...] } } let parsed: unknown; try { parsed = JSON.parse(value); } catch { console.error(`lspServers must be a JSON object, got invalid JSON: ${value}`); process.exit(1); } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { console.error(`lspServers must be a JSON object keyed by language id, got: ${value}`); process.exit(1); } stored.lspServers = parsed as Record; } else if (key === "contextWindow" || key === "maxIterations") { const n = Number(value); if (!Number.isFinite(n) || n <= 0) { console.error(`${key} must be a positive number, got "${value}".`); process.exit(1); } stored[key] = n; } else if (key === "maxOutputTokens") { const n = Number(value); if (!Number.isFinite(n) || n < 256 || n > 1_000_000) { console.error(`maxOutputTokens must be between 256 and 1000000, got "${value}".`); process.exit(1); } stored[key] = n; } else if (key === "autoCompactThreshold") { const n = Number(value); if (!Number.isFinite(n) || n < 0.1 || n > 0.95) { console.error(`autoCompactThreshold must be between 0.1 and 0.95, got "${value}".`); process.exit(1); } stored[key] = n; } else if (key === "requestTimeoutMs") { const n = Number(value); if (!Number.isFinite(n) || n < 10_000 || n > 1_800_000) { console.error(`requestTimeoutMs must be between 10000 and 1800000, got "${value}".`); process.exit(1); } stored[key] = n; } else if (key === "subagentTimeoutMs") { const n = Number(value); if (!Number.isFinite(n) || n < 1_000 || n > 3_600_000) { console.error(`subagentTimeoutMs must be between 1000 and 3600000, got "${value}".`); process.exit(1); } stored[key] = n; } else if (key === "maxRetries") { const n = Number(value); if (!Number.isFinite(n) || n < 0 || n > 10) { console.error(`maxRetries must be between 0 and 10, got "${value}".`); process.exit(1); } stored[key] = n; } else { stored[key] = value; } saveStoredConfig(stored); console.log(`Set ${key} = ${value}`); }); configCmd .command("path") .description("Print the path to the persisted config file") .action(() => { console.log(configFilePath()); }); const sessionsCmd = program.command("sessions").description("Manage saved conversations"); sessionsCmd .command("list") .description("List saved conversations, most recent first") .action(() => { const sessions = listSessions(); if (sessions.length === 0) { console.log("No saved sessions found."); return; } for (const s of sessions) { console.log(`${s.id} [${s.updatedAt}] (${s.model}, ${s.messageCount} msgs) ${s.title}`); } }); sessionsCmd .command("rm ") .description("Delete a saved conversation") .action((id: string) => { if (deleteSession(id)) { console.log(`Deleted session ${id}.`); } else if (loadSession(id)) { // The file exists but deleteSession() couldn't actually remove it (e.g. locked by another // process) — a different situation from "no such session", so say so distinctly. console.error(`Could not delete session "${id}" — the file may be in use by another process.`); process.exit(1); } else { console.error(`No saved session found with id "${id}".`); process.exit(1); } }); sessionsCmd .command("path") .description("Print the directory where sessions are stored") .action(() => { console.log(sessionsDir()); }); function collect(value: string, previous: string[]): string[] { previous.push(value); return previous; } function parseKeyValueList(pairs: string[], separator: string): Record { const result: Record = {}; for (const pair of pairs) { const idx = pair.indexOf(separator); if (idx === -1) { console.error(`Expected "KEY${separator}VALUE", got "${pair}".`); process.exit(1); } result[pair.slice(0, idx)] = pair.slice(idx + 1); } return result; } const mcpCmd = program .command("mcp") .description("Manage MCP servers (add/remove apply to your user-level config; project .mcp.json is read too)"); mcpCmd .command("add ") .description("Add an MCP server: stdio (--command) or remote (--url)") .option("--command ", "command to spawn for a stdio server") .option("--arg ", "argument to pass to the command (repeatable)", collect, [] as string[]) .option("--env ", "environment variable for the spawned command (repeatable)", collect, [] as string[]) .option("--url ", "URL for a remote (streamable HTTP) server") .option("--header ", "HTTP header for a remote server (repeatable)", collect, [] as string[]) .action((name: string, opts) => { if (opts.command && opts.url) { console.error("Specify either --command (stdio) or --url (remote), not both."); process.exit(1); } if (opts.url) { saveUserServer(name, { url: opts.url, headers: parseKeyValueList(opts.header, ":") }); } else if (opts.command) { saveUserServer(name, { command: opts.command, args: opts.arg, env: parseKeyValueList(opts.env, "=") }); } else { console.error("Specify --command for a stdio server or --url for a remote server."); process.exit(1); } console.log(`Added MCP server "${name}".`); }); mcpCmd .command("remove ") .description("Remove a user-level MCP server") .action((name: string) => { if (removeUserServer(name)) { console.log(`Removed MCP server "${name}".`); } else { console.error(`No user-level MCP server found with name "${name}".`); process.exit(1); } }); mcpCmd .command("list") .description("List configured MCP servers (user-level + project .mcp.json)") .action(() => { const { servers } = loadMergedServers(process.cwd()); const names = Object.keys(servers); if (names.length === 0) { console.log("No MCP servers configured."); return; } for (const name of names) { const cfg = servers[name]!; console.log( isHttpServerConfig(cfg) ? `${name} [http] ${cfg.url}` : `${name} [stdio] ${cfg.command} ${(cfg.args ?? []).join(" ")}`, ); } }); mcpCmd .command("path") .description("Print the path to the user-level MCP config file") .action(() => { console.log(userMcpFilePath()); }); function isRemoteSource(source: string): boolean { return /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(source) || source.startsWith("git@"); } function sanitizeDirName(source: string): string { const base = source.split(/[/\\]/).pop() ?? source; return base.replace(/\.git$/, "").replace(/[^a-zA-Z0-9_-]/g, "_"); } const pluginCmd = program .command("plugin") .description("Manage Claude Code-compatible plugins (slash commands, agents, and MCP servers)"); pluginCmd .command("add ") .description("Install a plugin from a local directory or a git URL") .action(async (source: string) => { try { let pluginPath: string; if (isRemoteSource(source)) { const destDir = path.join(pluginsDir(), sanitizeDirName(source)); if (existsSync(destDir)) { console.error(`A plugin is already cloned at ${destDir}. Run "locode plugin remove" first if you want to re-add it.`); process.exit(1); } mkdirSync(pluginsDir(), { recursive: true }); console.log(`Cloning ${source}...`); const result = await execa("git", ["clone", "--depth", "1", source, destDir], { reject: false }); if (result.exitCode !== 0) { console.error(`git clone failed: ${result.stderr || `exit code ${result.exitCode}`}`); process.exit(1); } pluginPath = destDir; } else { pluginPath = path.resolve(source); if (!existsSync(pluginPath)) { console.error(`No such directory: ${pluginPath}`); process.exit(1); } } const loaded = loadPlugin(pluginPath); addInstalledPlugin({ name: loaded.name, path: pluginPath, source }); console.log( `Added plugin "${loaded.name}": ${loaded.commands.length} command(s), ${loaded.agents.length} agent(s), ` + `${loaded.skills.length} skill(s), ${Object.keys(loaded.mcpServers).length} MCP server(s).`, ); } catch (err) { console.error(`Failed to add plugin: ${(err as Error).message}`); process.exit(1); } }); pluginCmd .command("remove ") .description("Uninstall a plugin (deregisters it — a git-cloned copy under locode's data dir is left on disk)") .action((name: string) => { if (removeInstalledPlugin(name)) { console.log(`Removed plugin "${name}".`); } else { console.error(`No installed plugin found with name "${name}".`); process.exit(1); } }); pluginCmd .command("list") .description("List installed plugins") .action(() => { const records = loadInstalledPlugins(); if (records.length === 0) { console.log("No plugins installed."); return; } for (const record of records) { try { const loaded = loadPlugin(record.path); console.log( `${loaded.name} [${record.path}] ${loaded.commands.length} command(s), ${loaded.agents.length} agent(s), ` + `${loaded.skills.length} skill(s), ${Object.keys(loaded.mcpServers).length} MCP server(s)`, ); } catch { console.log(`${record.name} [${record.path}] (failed to load — the directory may be missing)`); } } }); pluginCmd .command("path") .description("Print the directory where git-cloned plugins are stored") .action(() => { console.log(pluginsDir()); }); const hooksCmd = program .command("hooks") .description("Inspect configured hooks (edit hooks.json by hand — see `locode hooks path`)"); hooksCmd .command("list") .description("List configured hooks per lifecycle event (plugin + user + project, merged)") .action(() => { const config = loadMergedHooks(process.cwd()); const events = Object.entries(config); if (events.length === 0) { console.log("No hooks configured."); return; } for (const [event, entries] of events) { const count = entries!.reduce((sum, e) => sum + e.hooks.length, 0); console.log(`${event}: ${count} command(s)`); for (const entry of entries!) { for (const hook of entry.hooks) { if (hook.type === "command") { console.log(` [${entry.matcher || "*"}] command: ${hook.command}`); } else if (hook.type === "http") { console.log(` [${entry.matcher || "*"}] http: ${hook.method ?? "POST"} ${hook.url}`); } else if (hook.type === "prompt") { console.log(` [${entry.matcher || "*"}] prompt: ${hook.message} (not yet implemented)`); } } } } }); hooksCmd .command("path") .description("Print the path to the user-level hooks.json file") .action(() => { console.log(userHooksFilePath()); }); program.parseAsync();