/**
* Git Commands Extension
*
* Registers three commands that delegate to git-sub-agent:
* /git-commit [message] - Stage all changes and commit
* /git-push - Push commits to remote
* /git-commit-push [message] - Stage, commit, and push in one go
*
* Associated extension: sub-agents.ts (provides spawnSubagent infrastructure)
*
* Place in .pi/extensions/ or ~/.pi/agent/extensions/ for auto-discovery.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { discoverAgents, spawnSubagent, extractFinalOutput, type AgentDef } from "./sub-agents";
import { uiInput } from "./ui-helpers";
// ── Helpers ──────────────────────────────────────────────────
function getAgent(
ctx: { ui: { notify: (msg: string, type: string) => void } },
agent: AgentDef | undefined,
name: string,
): AgentDef | null {
if (!agent) {
ctx.ui.notify(`❌ ${name} not found (check agents/${name}.md)`, "error");
return null;
}
return agent;
}
/** Parse git-agent structured output into a clean summary. */
interface GitSummary {
status: "success" | "fail" | "unknown";
summary: string;
details: string[];
}
function parseGitOutput(output: string): GitSummary {
const result: GitSummary = { status: "unknown", summary: "", details: [] };
// Parse ...
const statusMatch = output.match(/([^<]*)<\/status>/);
if (statusMatch) {
const s = statusMatch[1].trim();
if (s.includes("✅") || s.includes("✔")) result.status = "success";
else if (s.includes("❌") || s.includes("✖")) result.status = "fail";
}
// Parse ...
// Anchor to start of line (^ with m flag) to avoid matching a nested
// tag inside content before the real top-level summary.
const summaryMatch = output.match(/^([\s\S]*?)<\/summary>/m);
if (summaryMatch) result.summary = summaryMatch[1].trim();
// Parse ... → extract each line
// Anchor to start of line to avoid matching nested inside content.
const detailsMatch = output.match(/^([\s\S]*?)<\/details>/m);
if (detailsMatch) {
const lines = detailsMatch[1]
.split("\n")
.map((l) => l.replace(/^[-*]\s*/, "").trim())
.filter(Boolean);
result.details = lines;
}
// Fallback: if no structured format, use raw lines
if (!statusMatch && !summaryMatch) {
const lines = output.split("\n").filter((l) => l.trim());
result.summary = lines[0] || "";
result.details = lines.slice(1).map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
// Infer status from content
if (output.includes("✅") || output.includes("success") || output.includes("完成")) {
result.status = "success";
} else if (output.includes("❌") || output.includes("fail") || output.includes("失败")) {
result.status = "fail";
}
}
return result;
}
/** Enrich a detail line with relevant emoji icons. */
function iconify(line: string): string {
const lc = line.toLowerCase();
if (lc.startsWith("commit")) return `📝 ${line}`;
if (lc.startsWith("push")) return `📤 ${line}`;
if (lc.includes("file") || lc.includes("文件")) return `📁 ${line}`;
if (lc.startsWith("branch") || lc.includes("branch")) return `🌿 ${line}`;
if (lc.startsWith("tag") || lc.includes("tag")) return `🏷️ ${line}`;
return ` ${line}`; // indent others
}
async function runSubAgent(
agent: AgentDef,
task: string,
ctx: {
cwd: string;
signal?: AbortSignal;
ui: {
setStatus: (key: string, status: string | undefined) => void;
notify: (msg: string, type: string) => void;
};
},
): Promise {
const startTime = Date.now();
ctx.ui.setStatus("subagent", "🤖 git-sub-agent working...");
try {
const result = await spawnSubagent(
agent,
task,
ctx.cwd,
ctx.signal,
undefined, // use agent's default timeout
(progress) => {
ctx.ui.setStatus("subagent", progress.slice(0, 50));
},
);
const dur = ((Date.now() - startTime) / 1000).toFixed(1);
// Extract output with fallback to raw stdout
let output = extractFinalOutput(result.output);
if (!output && result.output.trim()) {
const lines = result.output.split("\n").filter((l) => {
const t = l.trim();
return t && !t.startsWith("{") && !t.startsWith("[");
});
if (lines.length > 0) {
output = lines.join("\n").trim();
}
}
ctx.ui.setStatus("subagent", undefined);
if (output) {
const parsed = parseGitOutput(output);
const statusIcon = parsed.status === "success" ? "✅" : parsed.status === "fail" ? "❌" : "ℹ️";
const detailText = parsed.details.length > 0 ? ` | ${parsed.details.join(" | ")}` : "";
const msg = `${statusIcon} ${parsed.summary || "done"} (${dur}s)${detailText}`;
const notifyType = parsed.status === "fail" ? "error" : "success";
ctx.ui.notify(msg, notifyType);
} else if (result.exitCode !== 0) {
const errMsg = result.stderr || result.output.slice(0, 300) || "未知错误";
ctx.ui.notify(`❌ git-sub-agent 失败 (${dur}s): ${errMsg}`, "error");
} else {
ctx.ui.notify(`✅ git-sub-agent 完成 (${dur}s)`, "success");
}
} finally {
ctx.ui.setStatus("subagent", undefined);
}
}
// ── Extension ────────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
const agents = discoverAgents();
const gitAgent = agents.find((a) => a.name === "git-agent");
// ── /git-commit ────────────────────────────────────────────
pi.registerCommand("git-commit", {
description: "(sub-agent) Stage all changes and create a commit via git-sub-agent",
handler: async (args, ctx) => {
const agent = getAgent(ctx, gitAgent, "git-agent");
if (!agent) return;
let message = args.trim();
if (!message) {
const input = await uiInput(ctx, "Commit message", "直接回车让 AI 自动生成,或输入信息后提交...");
if (input === undefined) {
return;
}
message = input.trim();
}
const task = message
? `Stage all changes with git add -A, then commit with message: "${message}". Do NOT ask for confirmation.`
: `Stage all changes with git add -A, check the diff, write a Conventional Commits message, and commit. Do NOT ask for confirmation.`;
await runSubAgent(agent, task, ctx);
},
});
// ── /git-push ─────────────────────────────────────────────
pi.registerCommand("git-push", {
description: "(sub-agent) Push commits to remote via git-sub-agent",
handler: async (_args, ctx) => {
const agent = getAgent(ctx, gitAgent, "git-agent");
if (!agent) return;
await runSubAgent(
agent,
"Push commits to remote with git push. Do NOT ask for confirmation.",
ctx,
);
},
});
// ── /git-commit-push ──────────────────────────────────────
pi.registerCommand("git-commit-push", {
description: "(sub-agent) Stage, commit, and push via git-sub-agent",
handler: async (args, ctx) => {
const agent = getAgent(ctx, gitAgent, "git-agent");
if (!agent) return;
let message = args.trim();
if (!message) {
const input = await uiInput(ctx, "Commit message", "直接回车让 AI 自动生成,或输入信息后提交并推送...");
if (input === undefined) {
return;
}
message = input.trim();
}
const task = message
? `Stage all changes with git add -A, commit with message: "${message}", then push. Do NOT ask for confirmation.`
: `Stage all changes with git add -A, check the diff, write a Conventional Commits message, commit, then push. Do NOT ask for confirmation.`;
await runSubAgent(agent, task, ctx);
},
});
}