From 9642bdaeef1cbccb4adb9429062052f1d5477a43 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Fri, 25 Jul 2025 00:45:30 +0200 Subject: [PATCH] feat: git commands (#41) * feat: add /commit-and-push for AI-generated commits and pushes * fix: handle missing upstream branch in git push * feat: add git commit-and-push subcommand with AI assistance * feat: automate staging changes with git add in commit workflow * chore: bump version to 0.0.12 * revert package json * chore(release): bump version to 0.0.12 --- package-lock.json | 4 +- package.json | 2 +- src/hooks/use-input-handler.ts | 187 +++++++++++++++++++++++++++++++++ src/index.ts | 161 +++++++++++++++++++++++++++- 4 files changed, 349 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3951257..91a9ab4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vibe-kit/grok-cli", - "version": "0.0.10", + "version": "0.0.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vibe-kit/grok-cli", - "version": "0.0.10", + "version": "0.0.12", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index d7f8da9..fa2ca39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vibe-kit/grok-cli", - "version": "0.0.11", + "version": "0.0.12", "description": "An open-source AI agent that brings the power of Grok directly into your terminal.", "main": "dist/index.js", "bin": { diff --git a/src/hooks/use-input-handler.ts b/src/hooks/use-input-handler.ts index 347d826..1a0feec 100644 --- a/src/hooks/use-input-handler.ts +++ b/src/hooks/use-input-handler.ts @@ -57,6 +57,7 @@ export function useInputHandler({ { command: "/help", description: "Show help information" }, { command: "/clear", description: "Clear chat history" }, { command: "/models", description: "Switch Grok Model" }, + { command: "/commit-and-push", description: "AI commit & push to remote" }, { command: "/exit", description: "Exit the application" }, ]; @@ -104,6 +105,9 @@ Built-in Commands: /exit - Exit application exit, quit - Exit application +Git Commands: + /commit-and-push - AI-generated commit + push to remote + Keyboard Shortcuts: Shift+Tab - Toggle auto-edit mode (bypass confirmations) @@ -162,6 +166,189 @@ Available models: ${modelNames.join(", ")}`, return true; } + if (trimmedInput === "/commit-and-push") { + const userEntry: ChatEntry = { + type: "user", + content: "/commit-and-push", + timestamp: new Date(), + }; + setChatHistory((prev) => [...prev, userEntry]); + + setIsProcessing(true); + setIsStreaming(true); + + try { + // First check if there are any changes at all + const initialStatusResult = await agent.executeBashCommand("git status --porcelain"); + + if (!initialStatusResult.success || !initialStatusResult.output?.trim()) { + const noChangesEntry: ChatEntry = { + type: "assistant", + content: "No changes to commit. Working directory is clean.", + timestamp: new Date(), + }; + setChatHistory((prev) => [...prev, noChangesEntry]); + setIsProcessing(false); + setIsStreaming(false); + setInput(""); + return true; + } + + // Add all changes + const addResult = await agent.executeBashCommand("git add ."); + + if (!addResult.success) { + const addErrorEntry: ChatEntry = { + type: "assistant", + content: `Failed to stage changes: ${addResult.error || 'Unknown error'}`, + timestamp: new Date(), + }; + setChatHistory((prev) => [...prev, addErrorEntry]); + setIsProcessing(false); + setIsStreaming(false); + setInput(""); + return true; + } + + // Show that changes were staged + const addEntry: ChatEntry = { + type: "tool_result", + content: "Changes staged successfully", + timestamp: new Date(), + toolCall: { + id: `git_add_${Date.now()}`, + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ command: "git add ." }), + }, + }, + toolResult: addResult, + }; + setChatHistory((prev) => [...prev, addEntry]); + + // Get staged changes for commit message generation + const diffResult = await agent.executeBashCommand("git diff --cached"); + + // Generate commit message using AI + const commitPrompt = `Generate a concise, professional git commit message for these changes: + +Git Status: +${initialStatusResult.output} + +Git Diff (staged changes): +${diffResult.output || "No staged changes shown"} + +Follow conventional commit format (feat:, fix:, docs:, etc.) and keep it under 72 characters. +Respond with ONLY the commit message, no additional text.`; + + let commitMessage = ""; + let streamingEntry: ChatEntry | null = null; + + for await (const chunk of agent.processUserMessageStream(commitPrompt)) { + if (chunk.type === "content" && chunk.content) { + if (!streamingEntry) { + const newEntry = { + type: "assistant" as const, + content: `Generating commit message...\n\n${chunk.content}`, + timestamp: new Date(), + isStreaming: true, + }; + setChatHistory((prev) => [...prev, newEntry]); + streamingEntry = newEntry; + commitMessage = chunk.content; + } else { + commitMessage += chunk.content; + setChatHistory((prev) => + prev.map((entry, idx) => + idx === prev.length - 1 && entry.isStreaming + ? { ...entry, content: `Generating commit message...\n\n${commitMessage}` } + : entry + ) + ); + } + } else if (chunk.type === "done") { + if (streamingEntry) { + setChatHistory((prev) => + prev.map((entry) => + entry.isStreaming + ? { ...entry, content: `Generated commit message: "${commitMessage.trim()}"`, isStreaming: false } + : entry + ) + ); + } + break; + } + } + + // Execute the commit + const cleanCommitMessage = commitMessage.trim().replace(/^["']|["']$/g, ''); + const commitCommand = `git commit -m "${cleanCommitMessage}"`; + const commitResult = await agent.executeBashCommand(commitCommand); + + const commitEntry: ChatEntry = { + type: "tool_result", + content: commitResult.success + ? commitResult.output || "Commit successful" + : commitResult.error || "Commit failed", + timestamp: new Date(), + toolCall: { + id: `git_commit_${Date.now()}`, + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ command: commitCommand }), + }, + }, + toolResult: commitResult, + }; + setChatHistory((prev) => [...prev, commitEntry]); + + // If commit was successful, push to remote + if (commitResult.success) { + // First try regular push, if it fails try with upstream setup + let pushResult = await agent.executeBashCommand("git push"); + let pushCommand = "git push"; + + if (!pushResult.success && pushResult.error?.includes("no upstream branch")) { + pushCommand = "git push -u origin HEAD"; + pushResult = await agent.executeBashCommand(pushCommand); + } + + const pushEntry: ChatEntry = { + type: "tool_result", + content: pushResult.success + ? pushResult.output || "Push successful" + : pushResult.error || "Push failed", + timestamp: new Date(), + toolCall: { + id: `git_push_${Date.now()}`, + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ command: pushCommand }), + }, + }, + toolResult: pushResult, + }; + setChatHistory((prev) => [...prev, pushEntry]); + } + + } catch (error: any) { + const errorEntry: ChatEntry = { + type: "assistant", + content: `Error during commit and push: ${error.message}`, + timestamp: new Date(), + }; + setChatHistory((prev) => [...prev, errorEntry]); + } + + setIsProcessing(false); + setIsStreaming(false); + setInput(""); + return true; + } + const directBashCommands = [ "ls", "pwd", diff --git a/src/index.ts b/src/index.ts index 0c232d3..bb42e08 100755 --- a/src/index.ts +++ b/src/index.ts @@ -88,6 +88,112 @@ function loadBaseURL(): string | undefined { return baseURL; } +// Handle commit-and-push command in headless mode +async function handleCommitAndPushHeadless( + apiKey: string, + baseURL?: string, + model?: string +): Promise { + try { + const agent = new GrokAgent(apiKey, baseURL, model); + + // Configure confirmation service for headless mode (auto-approve all operations) + const confirmationService = ConfirmationService.getInstance(); + confirmationService.setSessionFlag("allOperations", true); + + console.log("🤖 Processing commit and push...\n"); + console.log("> /commit-and-push\n"); + + // First check if there are any changes at all + const initialStatusResult = await agent.executeBashCommand("git status --porcelain"); + + if (!initialStatusResult.success || !initialStatusResult.output?.trim()) { + console.log("❌ No changes to commit. Working directory is clean."); + process.exit(1); + } + + console.log("✅ git status: Changes detected"); + + // Add all changes + const addResult = await agent.executeBashCommand("git add ."); + + if (!addResult.success) { + console.log(`❌ git add: ${addResult.error || 'Failed to stage changes'}`); + process.exit(1); + } + + console.log("✅ git add: Changes staged"); + + // Get staged changes for commit message generation + const diffResult = await agent.executeBashCommand("git diff --cached"); + + // Generate commit message using AI + const commitPrompt = `Generate a concise, professional git commit message for these changes: + +Git Status: +${initialStatusResult.output} + +Git Diff (staged changes): +${diffResult.output || "No staged changes shown"} + +Follow conventional commit format (feat:, fix:, docs:, etc.) and keep it under 72 characters. +Respond with ONLY the commit message, no additional text.`; + + console.log("🤖 Generating commit message..."); + + const commitMessageEntries = await agent.processUserMessage(commitPrompt); + let commitMessage = ""; + + // Extract the commit message from the AI response + for (const entry of commitMessageEntries) { + if (entry.type === "assistant" && entry.content.trim()) { + commitMessage = entry.content.trim(); + break; + } + } + + if (!commitMessage) { + console.log("❌ Failed to generate commit message"); + process.exit(1); + } + + // Clean the commit message + const cleanCommitMessage = commitMessage.replace(/^["']|["']$/g, ''); + console.log(`✅ Generated commit message: "${cleanCommitMessage}"`); + + // Execute the commit + const commitCommand = `git commit -m "${cleanCommitMessage}"`; + const commitResult = await agent.executeBashCommand(commitCommand); + + if (commitResult.success) { + console.log(`✅ git commit: ${commitResult.output?.split('\n')[0] || 'Commit successful'}`); + + // If commit was successful, push to remote + // First try regular push, if it fails try with upstream setup + let pushResult = await agent.executeBashCommand("git push"); + + if (!pushResult.success && pushResult.error?.includes("no upstream branch")) { + console.log("🔄 Setting upstream and pushing..."); + pushResult = await agent.executeBashCommand("git push -u origin HEAD"); + } + + if (pushResult.success) { + console.log(`✅ git push: ${pushResult.output?.split('\n')[0] || 'Push successful'}`); + } else { + console.log(`❌ git push: ${pushResult.error || 'Push failed'}`); + process.exit(1); + } + } else { + console.log(`❌ git commit: ${commitResult.error || 'Commit failed'}`); + process.exit(1); + } + + } catch (error: any) { + console.error("❌ Error during commit and push:", error.message); + process.exit(1); + } +} + // Headless mode processing function async function processPromptHeadless( prompt: string, @@ -158,9 +264,9 @@ async function processPromptHeadless( program .name("grok") .description( - "A conversational AI CLI tool powered by Grok-3 with text editor capabilities" + "A conversational AI CLI tool powered by Grok with text editor capabilities" ) - .version("1.0.0") + .version("1.0.1") .option("-d, --directory ", "set working directory", process.cwd()) .option("-k, --api-key ", "Grok API key (or set GROK_API_KEY env var)") .option( @@ -217,4 +323,55 @@ program } }); +// Git subcommand +const gitCommand = program + .command("git") + .description("Git operations with AI assistance"); + +gitCommand + .command("commit-and-push") + .description("Generate AI commit message and push to remote") + .option("-d, --directory ", "set working directory", process.cwd()) + .option("-k, --api-key ", "Grok API key (or set GROK_API_KEY env var)") + .option( + "-u, --base-url ", + "Grok API base URL (or set GROK_BASE_URL env var)" + ) + .option( + "-m, --model ", + "AI model to use (e.g., gemini-2.5-pro, grok-4-latest)" + ) + .action(async (options) => { + if (options.directory) { + try { + process.chdir(options.directory); + } catch (error: any) { + console.error( + `Error changing directory to ${options.directory}:`, + error.message + ); + process.exit(1); + } + } + + try { + // Get API key from options, environment, or user settings + const apiKey = options.apiKey || loadApiKey(); + const baseURL = options.baseUrl || loadBaseURL(); + const model = options.model; + + if (!apiKey) { + console.error( + "❌ Error: API key required. Set GROK_API_KEY environment variable, use --api-key flag, or save to ~/.grok/user-settings.json" + ); + process.exit(1); + } + + await handleCommitAndPushHeadless(apiKey, baseURL, model); + } catch (error: any) { + console.error("❌ Error during git commit-and-push:", error.message); + process.exit(1); + } + }); + program.parse();