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>
299 lines
13 KiB
TypeScript
299 lines
13 KiB
TypeScript
import { execa } from "execa";
|
|
import { z } from "zod";
|
|
import { truncate } from "../utils/truncate.js";
|
|
import type { ToolDef } from "./types.js";
|
|
|
|
async function tryGit(args: string[], cwd: string): Promise<string> {
|
|
try {
|
|
const result = await execa("git", args, { cwd, reject: false });
|
|
return result.exitCode === 0 ? result.stdout : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function runGit(args: string[], cwd: string) {
|
|
return execa("git", args, { cwd, reject: false });
|
|
}
|
|
|
|
// --- git_status: read-only inspection, auto-runs like grep/read_file ---
|
|
|
|
const statusSchema = z.object({
|
|
operation: z.enum(["status", "diff", "log", "show", "branches"]),
|
|
paths: z.array(z.string()).optional().describe("Limit `status`/`diff` to these paths, relative to the working directory."),
|
|
staged: z.boolean().optional().describe("For `diff`: show staged changes (--cached) instead of the working tree diff."),
|
|
ref: z.string().optional().describe("For `show`: the commit/ref to inspect (default HEAD). For `log`: start from this ref."),
|
|
maxCount: z.number().int().min(1).max(200).optional().describe("For `log`: maximum number of commits to return (default 20)."),
|
|
});
|
|
|
|
export const gitStatusTool: ToolDef<z.infer<typeof statusSchema>> = {
|
|
name: "git_status",
|
|
description:
|
|
"Inspect the git repository at the working directory: `status` (working tree state), `diff` (unstaged or, with staged:true, " +
|
|
"staged changes), `log` (recent commits), `show` (a specific commit/ref, default HEAD), or `branches` (local branches with " +
|
|
"upstream tracking info). Read-only — runs automatically without confirmation.",
|
|
schema: statusSchema,
|
|
mutating: false,
|
|
handler: async ({ operation, paths, staged, ref, maxCount }, ctx) => {
|
|
const pathArgs = paths?.length ? ["--", ...paths] : [];
|
|
const args: string[] = (() => {
|
|
switch (operation) {
|
|
case "status":
|
|
return ["status", "--short", "--branch", ...pathArgs];
|
|
case "diff":
|
|
return ["diff", ...(staged ? ["--cached"] : []), ...pathArgs];
|
|
case "log":
|
|
return ["log", `--max-count=${maxCount ?? 20}`, "--oneline", "--decorate", ...(ref ? [ref] : [])];
|
|
case "show":
|
|
return ["show", ref ?? "HEAD"];
|
|
case "branches":
|
|
return ["branch", "--list", "-vv"];
|
|
}
|
|
})();
|
|
|
|
const result = await runGit(args, ctx.cwd);
|
|
if (result.exitCode !== 0) {
|
|
return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) };
|
|
}
|
|
return { output: truncate(result.stdout) };
|
|
},
|
|
};
|
|
|
|
// --- git_commit: mutating operations, gated behind a confirmation prompt ---
|
|
|
|
// Ref-like arguments (branch names, refs, remote names) are passed to git as bare positional
|
|
// args with no `--` separator. A value starting with `-` would otherwise be parsed by git as a
|
|
// flag instead of a ref/branch (e.g. ref: "--hard" turning a plain reset into a hard reset) —
|
|
// reject it at the schema boundary so it can never reach execa's argv.
|
|
function refLike(description: string) {
|
|
return z
|
|
.string()
|
|
.refine((v) => !v.startsWith("-"), { message: 'must not start with "-" (would be parsed as a git flag, not a ref/branch name)' })
|
|
.describe(description)
|
|
.optional();
|
|
}
|
|
|
|
const commitSchema = z.object({
|
|
operation: z.enum([
|
|
"add",
|
|
"commit",
|
|
"create_branch",
|
|
"checkout",
|
|
"push",
|
|
"reset",
|
|
"stash",
|
|
"merge",
|
|
"rebase",
|
|
"delete_branch",
|
|
]),
|
|
paths: z.array(z.string()).optional().describe("For `add`: paths to stage. For `stash push`: paths to stash. Omit to stage all changes."),
|
|
message: z.string().optional().describe("For `commit`: commit message. For `stash push`: stash message."),
|
|
branchName: refLike("Branch name. Required for create_branch/checkout/merge/rebase/delete_branch."),
|
|
createIfMissing: z.boolean().optional().describe("For `checkout`: create the branch if it doesn't exist yet (like `checkout -b`)."),
|
|
remote: refLike('For `push`: remote name (default "origin").'),
|
|
setUpstream: z.boolean().optional().describe("For `push`: set upstream tracking — needed the first time a new branch is pushed."),
|
|
ref: refLike("For `reset`: ref to reset to (default HEAD). For `merge`/`rebase`: alternative ref if branchName omitted."),
|
|
mode: z.enum(["soft", "mixed", "hard"]).optional().describe("For `reset`: reset mode (default mixed)."),
|
|
strategy: z.enum(["fast-forward", "no-ff", "ff-only"]).optional().describe("For `merge`: merge strategy."),
|
|
stashRef: refLike("For `stash pop`: stash reference (default stash@{0})."),
|
|
force: z.boolean().optional().describe("For `delete_branch`: force delete an unmerged branch."),
|
|
});
|
|
|
|
export const gitCommitTool: ToolDef<z.infer<typeof commitSchema>> = {
|
|
name: "git_commit",
|
|
description:
|
|
"Make changes to the git repository at the working directory: `add` (stage paths, or all changes if omitted), `commit` " +
|
|
"(requires `message`; commit staged changes), `create_branch` (requires `branchName`; branch at current HEAD without switching), " +
|
|
"`checkout` (requires `branchName`; switch branches, or create-and-switch with createIfMissing), `push` (push current branch), " +
|
|
"`reset` (reset current branch; default mixed, default ref HEAD), `stash` (push or pop a stash), `merge` (merge branchName into " +
|
|
"current branch), `rebase` (rebase current branch onto branchName), or `delete_branch` (requires `branchName`; delete a local branch). " +
|
|
"Each mutating use requires the user's confirmation with a preview of the affected state.",
|
|
schema: commitSchema,
|
|
mutating: true,
|
|
preview: async (args, ctx) => buildPreview(args, ctx.cwd),
|
|
handler: async (args, ctx) => runMutatingOperation(args, ctx.cwd),
|
|
};
|
|
|
|
async function buildPreview(
|
|
{
|
|
operation,
|
|
paths,
|
|
message,
|
|
branchName,
|
|
createIfMissing,
|
|
remote,
|
|
setUpstream,
|
|
ref,
|
|
mode,
|
|
strategy,
|
|
stashRef,
|
|
force,
|
|
}: z.infer<typeof commitSchema>,
|
|
cwd: string,
|
|
): Promise<string> {
|
|
switch (operation) {
|
|
case "add": {
|
|
const target = paths?.length ? paths.join(", ") : "all changes";
|
|
const status = await tryGit(["status", "--short", ...(paths?.length ? ["--", ...paths] : [])], cwd);
|
|
return status ? `Stage ${target}:\n\n${status}` : `Stage ${target} (no changes detected).`;
|
|
}
|
|
case "commit": {
|
|
const staged = await tryGit(["diff", "--cached"], cwd);
|
|
if (!staged) return "Nothing is staged — this commit will fail. Use the `add` operation first.";
|
|
return `Commit message: "${message ?? "(none provided — this will fail)"}"\n\n${staged}`;
|
|
}
|
|
case "create_branch":
|
|
return `Create new branch "${branchName}" pointing at current HEAD (does not switch to it).`;
|
|
case "checkout": {
|
|
const status = await tryGit(["status", "--short"], cwd);
|
|
const action = createIfMissing ? `Create and switch to new branch "${branchName}"` : `Switch to branch "${branchName}"`;
|
|
return status ? `${action}.\n\nUncommitted changes that will carry over:\n${status}` : `${action}. Working tree is clean.`;
|
|
}
|
|
case "push": {
|
|
const remoteName = remote ?? "origin";
|
|
const branch = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim() || "HEAD";
|
|
const ahead =
|
|
(await tryGit(["log", "--oneline", "@{u}.."], cwd)) || (await tryGit(["log", "--oneline", "-n", "10"], cwd));
|
|
const upstreamNote = setUpstream ? " (setting upstream tracking)" : "";
|
|
return ahead
|
|
? `Push "${branch}" to "${remoteName}"${upstreamNote} — commits to push:\n\n${ahead}`
|
|
: `Push "${branch}" to "${remoteName}"${upstreamNote}.`;
|
|
}
|
|
case "reset": {
|
|
const targetRef = ref ?? "HEAD";
|
|
const modeText = mode ?? "mixed";
|
|
const diff = modeText === "hard" ? await tryGit(["diff", "--stat", "HEAD"], cwd) : await tryGit(["diff", "--cached", "--stat"], cwd);
|
|
return `Reset (${modeText}) to ${targetRef}.${diff ? `\n\nFiles affected:\n${diff}` : ""}`;
|
|
}
|
|
case "stash": {
|
|
if (stashRef) {
|
|
const stash = await tryGit(["stash", "show", "-p", stashRef], cwd);
|
|
return stash ? `Pop stash ${stashRef}:\n\n${stash}` : `Pop stash ${stashRef}.`;
|
|
}
|
|
const status = await tryGit(["status", "--short", ...(paths?.length ? ["--", ...paths] : [])], cwd);
|
|
return status
|
|
? `Stash ${message ? `"${message}"` : "changes"}${paths?.length ? ` (paths: ${paths.join(", ")})` : ""}:\n\n${status}`
|
|
: "No local changes to stash.";
|
|
}
|
|
case "merge": {
|
|
const into = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim() || "HEAD";
|
|
const target = branchName ?? ref;
|
|
const log = target ? await tryGit(["log", "--oneline", `${into}..${target}`], cwd) : "";
|
|
return target
|
|
? `Merge "${target}" into "${into}"${strategy ? ` (strategy: ${strategy})` : ""}.${log ? `\n\nCommits to merge:\n${log}` : ""}`
|
|
: "merge requires `branchName` or `ref`.";
|
|
}
|
|
case "rebase": {
|
|
const onto = branchName ?? ref;
|
|
const current = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim() || "HEAD";
|
|
const log = onto ? await tryGit(["log", "--oneline", `${onto}..${current}`], cwd) : "";
|
|
return onto
|
|
? `Rebase "${current}" onto "${onto}".${log ? `\n\nCommits that will be replayed:\n${log}` : ""}`
|
|
: "rebase requires `branchName` or `ref`.";
|
|
}
|
|
case "delete_branch": {
|
|
const log = branchName ? await tryGit(["log", "--oneline", `${branchName}..HEAD`], cwd) : "";
|
|
const note = force ? " (force delete)" : "";
|
|
return branchName
|
|
? `Delete local branch "${branchName}"${note}.${log ? `\n\nCommits on ${branchName} not in HEAD:\n${log}` : ""}`
|
|
: "delete_branch requires `branchName`.";
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runMutatingOperation(args: z.infer<typeof commitSchema>, cwd: string): Promise<unknown> {
|
|
const {
|
|
operation,
|
|
paths,
|
|
message,
|
|
branchName,
|
|
createIfMissing,
|
|
remote,
|
|
setUpstream,
|
|
ref,
|
|
mode,
|
|
strategy,
|
|
stashRef,
|
|
force,
|
|
} = args;
|
|
|
|
const gitArgs: string[] = (() => {
|
|
switch (operation) {
|
|
case "add":
|
|
return ["add", ...(paths?.length ? paths : ["-A"])];
|
|
case "commit": {
|
|
if (!message) throw new Error("`message` is required for the commit operation.");
|
|
return ["commit", "-m", message];
|
|
}
|
|
case "create_branch": {
|
|
if (!branchName) throw new Error("`branchName` is required for the create_branch operation.");
|
|
return ["branch", branchName];
|
|
}
|
|
case "checkout": {
|
|
if (!branchName) throw new Error("`branchName` is required for the checkout operation.");
|
|
return createIfMissing ? ["checkout", "-b", branchName] : ["checkout", branchName];
|
|
}
|
|
case "push":
|
|
return ["push", ...(setUpstream ? ["-u"] : []), remote ?? "origin", "HEAD"];
|
|
case "reset": {
|
|
const targetRef = ref ?? "HEAD";
|
|
return ["reset", ...(mode ? [`--${mode}`] : []), targetRef];
|
|
}
|
|
case "stash": {
|
|
if (stashRef) {
|
|
return ["stash", "pop", stashRef];
|
|
}
|
|
const stashArgs = ["stash", "push"];
|
|
if (message) stashArgs.push("-m", message);
|
|
if (paths?.length) stashArgs.push("--", ...paths);
|
|
return stashArgs;
|
|
}
|
|
case "merge": {
|
|
const target = branchName ?? ref;
|
|
if (!target) throw new Error("`merge` requires `branchName` or `ref`.");
|
|
const mergeArgs = ["merge"];
|
|
if (strategy === "no-ff") mergeArgs.push("--no-ff");
|
|
if (strategy === "ff-only") mergeArgs.push("--ff-only");
|
|
mergeArgs.push(target);
|
|
return mergeArgs;
|
|
}
|
|
case "rebase": {
|
|
const onto = branchName ?? ref;
|
|
if (!onto) throw new Error("`rebase` requires `branchName` or `ref`.");
|
|
return ["rebase", onto];
|
|
}
|
|
case "delete_branch": {
|
|
if (!branchName) throw new Error("`branchName` is required for the delete_branch operation.");
|
|
return ["branch", force ? "-D" : "-d", branchName];
|
|
}
|
|
}
|
|
})();
|
|
|
|
const result = await runGit(gitArgs, cwd);
|
|
if (result.exitCode !== 0) {
|
|
return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) };
|
|
}
|
|
const output = truncate(result.stdout || result.stderr);
|
|
|
|
switch (operation) {
|
|
case "add":
|
|
return { staged: paths?.length ? paths : ["(all changes)"] };
|
|
case "create_branch":
|
|
return { created: branchName };
|
|
case "checkout":
|
|
return { switchedTo: branchName };
|
|
case "reset":
|
|
return { resetTo: ref ?? "HEAD", mode: mode ?? "mixed" };
|
|
case "stash":
|
|
return stashRef ? { popped: stashRef } : { stashed: message ?? "(no message)" };
|
|
case "merge":
|
|
return { merged: branchName ?? ref, output };
|
|
case "rebase":
|
|
return { rebasedOnto: branchName ?? ref, output };
|
|
case "delete_branch":
|
|
return { deleted: branchName };
|
|
default:
|
|
return { output };
|
|
}
|
|
}
|