Add plugin/hook/skill support, images, @mentions, dashboard, and stability fixes

Builds out locode's Claude Code plugin parity: MCP servers, slash commands,
sub-agents, hooks (12 lifecycle events), and skills, all loadable from a
local path or git URL. Also adds image support (read_file, /import), @-mention
file autocomplete, a /dashboard stats view, /export with an editable filename
prompt, an expanded git tool (reset/stash/merge/rebase/delete_branch), and a
redesigned status bar.

On top of that, a review pass found and fixed 10 correctness/stability bugs:
argument-injection in git reset/merge/rebase (a ref like "--hard" was parsed
as a flag), tool-call image results interleaving with native tool_call_id
messages and breaking OpenAI-compatible message ordering, backgrounded bash
jobs still being silently killed by their original timeout with the kill
masked as a clean exit, a SubagentStart hook's block being ignored, the
sub-agent timeout clock starting before the hook it should exclude, a
template-expansion bug that could re-substitute $1..$9 placeholders,
unbounded background-job output buffers, a missing directory-target fallback
in /export, and image size caps checked after reading the whole file instead
of before.

Session saves and exports now go through a shared atomic
write-then-rename helper so a crash can't leave a truncated file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-07 18:17:45 +09:00
co-authored by Claude Sonnet 5
parent 265f8930a3
commit 2a35f40c73
64 changed files with 3840 additions and 338 deletions
+156 -4
View File
@@ -1,10 +1,16 @@
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();
@@ -108,10 +114,17 @@ configCmd
configCmd
.command("set <key> <value>")
.description("Persist a config value (backend, model, baseUrl, contextWindow, maxIterations)")
.description("Persist a config value (backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold)")
.action((key: string, value: string) => {
if (key !== "backend" && key !== "model" && key !== "baseUrl" && key !== "contextWindow" && key !== "maxIterations") {
console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxIterations`);
if (
key !== "backend" &&
key !== "model" &&
key !== "baseUrl" &&
key !== "contextWindow" &&
key !== "maxIterations" &&
key !== "autoCompactThreshold"
) {
console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold`);
process.exit(1);
}
const stored = loadStoredConfig();
@@ -122,6 +135,13 @@ configCmd
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 {
stored[key] = value;
}
@@ -233,7 +253,7 @@ mcpCmd
.command("list")
.description("List configured MCP servers (user-level + project .mcp.json)")
.action(() => {
const servers = loadMergedServers(process.cwd());
const { servers } = loadMergedServers(process.cwd());
const names = Object.keys(servers);
if (names.length === 0) {
console.log("No MCP servers configured.");
@@ -254,4 +274,136 @@ mcpCmd
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();