import { Type } from "@sinclair/typebox"; import { StringEnum } from "@mariozechner/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { trimOutput } from "@hyperprior/pi-shared"; const CommitParams = Type.Object({ action: StringEnum(["status", "commit"] as const), message: Type.Optional(Type.String({ description: "Commit message (required when action=commit)" })), includeUntracked: Type.Optional(Type.Boolean({ description: "Whether to include untracked files in commit" })), }); type CommitResult = { status: string; staged: boolean; commitMessage?: string; hash?: string; stdout: string; changedFiles: string[]; }; function summarizeStatus(raw: string): string { const lines = raw .split("\n") .map((line) => line.trim()) .filter(Boolean) .slice(0, 20); if (lines.length === 0) { return "clean"; } const staged = lines.filter((l) => l.startsWith("A ") || l.startsWith("M ") || l.startsWith("D ")); const unstaged = lines.filter((l) => l[1] === "M" || l[1] === "D" || l[1] === "A"); return `${lines.length} changed file(s): ${staged.length} staged, ${unstaged.length} unstaged`; } async function gitStatus(pi: ExtensionAPI): Promise { const status = await pi.exec("git", ["status", "--porcelain"]); return status.stdout || ""; } async function runCommit( pi: ExtensionAPI, ctx: ExtensionContext, message: string, includeUntracked: boolean, ): Promise { const before = await gitStatus(pi); const changedFiles = before .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => line.slice(3)); if (!changedFiles.length) { return { status: "nothing-to-commit", staged: false, stdout: "No changes to commit.", changedFiles: [], }; } if (includeUntracked) { await pi.exec("git", ["add", "-A"]); } const addResult = await pi.exec("git", ["add", "."]); if (addResult.code !== 0) { return { status: "add-failed", staged: false, stdout: trimOutput(addResult.stderr + "\n" + addResult.stdout), changedFiles, }; } const { code, stdout, stderr } = await pi.exec("git", ["commit", "-m", message]); if (code !== 0) { return { status: "commit-failed", staged: true, commitMessage: message, stdout: trimOutput((stdout || "") + "\n" + (stderr || "")), changedFiles, }; } const hash = await pi.exec("git", ["rev-parse", "--short", "HEAD"]); return { status: "committed", staged: true, commitMessage: message, hash: hash.stdout?.trim(), stdout: trimOutput(stdout || ""), changedFiles, }; } export default function (pi: ExtensionAPI) { pi.registerTool({ name: "hyperpi_commit", label: "Hyperpi Commit", description: "Check git status and create a commit message-aware commit.", parameters: CommitParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const action = params.action; if (action === "status") { const statusText = await gitStatus(pi); const summary = summarizeStatus(statusText); return { content: [{ type: "text", text: summary }], details: { changedFiles: statusText.split("\n").filter(Boolean) } as const, }; } const defaultMessage = `chore: update from AI session`; const message = (params.message || "").trim() || defaultMessage; const result = await runCommit(pi, ctx, message, params.includeUntracked ?? true); const lines = [ `status: ${result.status}`, `files: ${result.changedFiles.length}`, result.commitMessage ? `message: ${result.commitMessage}` : "", result.hash ? `commit: ${result.hash}` : "", `stdout:\n${result.stdout}`, ].filter(Boolean); return { content: [{ type: "text", text: lines.join("\n") }], details: result as any, }; }, }); pi.registerCommand("hyper-commit", { description: "Run a repo commit helper. Usage: /hyper-commit [message]", handler: async (args, ctx) => { const message = args?.trim() || "chore: update"; if (!ctx.hasUI) { ctx.ui.notify("/hyper-commit requires interactive mode", "warning"); return; } const status = await gitStatus(pi); if (!status.trim()) { ctx.ui.notify("No changes to commit.", "info"); return; } const result = await runCommit(pi, ctx, message, true); if (result.status === "committed") { ctx.ui.notify(`Committed ${result.hash ?? "(unknown)"}: ${result.commitMessage}`, "info"); } else { ctx.ui.notify(`Commit failed: ${result.status}`, "error"); } }, }); }