Files
locode/src/cli.ts
T
kimandClaude Sonnet 5 c649fdf6ed feat: task persistence, memoized UI components, hook/delete-failure safety
locode-authored (dogfooded) changes:
- TaskStore.toJSON/fromJSON: task_create/list state now survives session
  resume instead of resetting to empty (session.ts, task.ts, sessionStore.ts).
- StatusBar/ChatInput/ThinkingIndicator wrapped in memo() to skip re-renders
  during the ~30fps streaming-text update loop.
- HistoryItemView's todos list keys off t.content instead of array index
  (TodoItem has no id field; content is the best stable key available).
- sessionStore.ts read paths now log (console.warn) on corrupt/unreadable
  files instead of silently swallowing the error.

Fixes on top, found in review:
- safeRunHooksForEvent() wraps the 4 remaining unprotected hook call sites
  (PreToolUse, PermissionRequest, PostToolUse, SubagentStart) — these fire on
  every tool call/sub-agent, far more often than the 3 sites already wrapped
  (Stop/SessionStart/UserPromptSubmit), so a broken hook script was still able
  to abort an in-flight turn.
- deleteSession() no longer reports success when unlinkSync fails for a
  reason other than ENOENT (e.g. EPERM/EBUSY from a Windows file lock) — it
  used to drop the index entry and return true anyway, orphaning the file on
  disk with no way to reference it again. `sessions rm` in cli.ts now
  distinguishes "not found" from "found but couldn't delete" in its message.
- ChatInput's memo() was a no-op: onSubmit={handleSubmit} passed a fresh
  closure every render since handleSubmit isn't wrapped in useCallback.
  Rather than force a ~300-line function (which reads isThinking/streamingText
  — both changing every streaming frame) into a dependency array, added a
  handleSubmitRef + stable wrapper, the same ref-indirection this file
  already uses for phaseRef.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LxiAaGhSD4DRVYYQZ5GJjm
2026-08-24 15:02:34 +09:00

467 lines
16 KiB
TypeScript

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 <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(pkg.version);
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, 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: { "<languageId>": { "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<string, { command: string; args?: string[]; extensions?: string[] }>;
} 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 <id>")
.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<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();