- Add todo_write tool with live checklist rendering in the UI - Add permission modes (default/plan/auto-edit/auto-accept) cycled via Shift+Tab or /perm - Load CLAUDE.md/AGENTS.md as project instructions into the system prompt - Forward sub-agent tool calls/results to parent UI panel - Auto-compact context mid-turn and keep model moving with a synthetic user turn - Prune older image_url parts to cap vision token cost - Idle-abort guard for stalled streaming backends - Improve background bash jobs, kill whole process trees on timeout/exit - Expand hooks: command/http hooks, blocking, JSON outputSchema - MCP: JSON schema → Zod conversion, parallel connection, duplicate server detection - Add tests for loop, confirmFn, projectInstructions, readFile, bash, todoWrite, contextWindowCache
428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
import { execa } from "execa";
|
|
import { existsSync, mkdirSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { Command } from "commander";
|
|
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, 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 <name>", "backend to use: ollama or lmstudio")
|
|
.option("--base-url <url>", "override the backend's base URL");
|
|
}
|
|
|
|
program
|
|
.name("locode")
|
|
.description("Agentic coding CLI for local models via Ollama and LM Studio")
|
|
.version("0.3.1");
|
|
|
|
addBackendOptions(program)
|
|
.option("-m, --model <name>", "model name as known to the backend")
|
|
.option(
|
|
"--tool-mode <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 <key> <value>")
|
|
.description("Persist a config value (backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs)")
|
|
.action((key: string, value: string) => {
|
|
if (
|
|
key !== "backend" &&
|
|
key !== "model" &&
|
|
key !== "baseUrl" &&
|
|
key !== "contextWindow" &&
|
|
key !== "maxIterations" &&
|
|
key !== "autoCompactThreshold" &&
|
|
key !== "requestTimeoutMs" &&
|
|
key !== "subagentTimeoutMs"
|
|
) {
|
|
console.error(
|
|
`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const stored = loadStoredConfig();
|
|
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 === "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 {
|
|
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 <id>")
|
|
.description("Delete a saved conversation")
|
|
.action((id: string) => {
|
|
if (deleteSession(id)) {
|
|
console.log(`Deleted session ${id}.`);
|
|
} 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<string, string> {
|
|
const result: Record<string, string> = {};
|
|
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 <name>")
|
|
.description("Add an MCP server: stdio (--command) or remote (--url)")
|
|
.option("--command <cmd>", "command to spawn for a stdio server")
|
|
.option("--arg <value>", "argument to pass to the command (repeatable)", collect, [] as string[])
|
|
.option("--env <KEY=VALUE>", "environment variable for the spawned command (repeatable)", collect, [] as string[])
|
|
.option("--url <url>", "URL for a remote (streamable HTTP) server")
|
|
.option("--header <KEY:VALUE>", "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 <cmd> for a stdio server or --url <url> for a remote server.");
|
|
process.exit(1);
|
|
}
|
|
console.log(`Added MCP server "${name}".`);
|
|
});
|
|
|
|
mcpCmd
|
|
.command("remove <name>")
|
|
.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 <source>")
|
|
.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 <name>")
|
|
.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();
|