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:
+156
-4
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user