├── .env.example ├── .github ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── feature_request.yml │ └── bug_report.yml └── workflows │ ├── typecheck.yml │ └── security.yml ├── src ├── utils │ ├── settings.ts │ ├── custom-instructions.ts │ ├── model-config.ts │ ├── token-counter.ts │ ├── confirmation-service.ts │ ├── text-utils.ts │ └── settings-manager.ts ├── tools │ ├── index.ts │ ├── bash.ts │ ├── confirmation-tool.ts │ ├── todo-tool.ts │ ├── search.ts │ └── morph-editor.ts ├── ui │ ├── utils │ │ ├── colors.ts │ │ ├── code-colorizer.tsx │ │ └── markdown-renderer.tsx │ ├── shared │ │ └── max-sized-box.tsx │ ├── components │ │ ├── model-selection.tsx │ │ ├── mcp-status.tsx │ │ ├── command-suggestions.tsx │ │ ├── loading-spinner.tsx │ │ ├── api-key-input.tsx │ │ ├── chat-input.tsx │ │ ├── confirmation-dialog.tsx │ │ ├── chat-history.tsx │ │ ├── diff-renderer.tsx │ │ └── chat-interface.tsx │ └── app.tsx ├── types │ └── index.ts ├── mcp │ ├── config.ts │ ├── client.ts │ └── transports.ts ├── hooks │ ├── use-input-history.ts │ └── use-enhanced-input.ts ├── grok │ ├── client.ts │ └── tools.ts ├── agent │ └── index.ts └── commands │ └── mcp.ts ├── .eslintrc.js ├── tsconfig.json ├── LICENSE ├── .npmignore ├── .gitignore ├── package.json └── README.md /.env.example: -------------------------------------------------------------------------------- 1 | # Grok API Configuration 2 | GROK_API_KEY=your_grok_api_key_here 3 | 4 | # Optional: Custom Grok API Base URL 5 | # GROK_BASE_URL=https://api.x.ai/v1 -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What does this PR do? 2 | 3 | Describe your changes here. 4 | 5 | Fixes # 6 | 7 | ## Checklist 8 | 9 | - [ ] I tested my changes 10 | - [ ] I reviewed my own code -------------------------------------------------------------------------------- /src/utils/settings.ts: -------------------------------------------------------------------------------- 1 | // This file is kept for potential future convenience functions 2 | // All settings management should use getSettingsManager() from './settings-manager.js' 3 | 4 | export { getSettingsManager, UserSettings, ProjectSettings } from './settings-manager.js'; 5 | -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- 1 | export { BashTool } from "./bash.js"; 2 | export { TextEditorTool } from "./text-editor.js"; 3 | export { MorphEditorTool } from "./morph-editor.js"; 4 | export { TodoTool } from "./todo-tool.js"; 5 | export { ConfirmationTool } from "./confirmation-tool.js"; 6 | export { SearchTool } from "./search.js"; 7 | -------------------------------------------------------------------------------- /src/ui/utils/colors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Color constants for the CLI interface 3 | */ 4 | export const Colors = { 5 | AccentYellow: 'yellow', 6 | Gray: 'gray', 7 | Red: 'red', 8 | Green: 'green', 9 | Blue: 'blue', 10 | Cyan: 'cyan', 11 | Magenta: 'magenta', 12 | White: 'white', 13 | Black: 'black' 14 | } as const; -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: ['@typescript-eslint'], 4 | extends: [ 5 | 'eslint:recommended', 6 | '@typescript-eslint/recommended', 7 | ], 8 | env: { 9 | node: true, 10 | es6: true, 11 | }, 12 | rules: { 13 | '@typescript-eslint/no-unused-vars': 'error', 14 | '@typescript-eslint/no-explicit-any': 'warn', 15 | }, 16 | }; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: "[Feature]: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Feature Description 10 | description: What feature would you like to see? 11 | placeholder: Describe the feature you'd like 12 | validations: 13 | required: true -------------------------------------------------------------------------------- /src/ui/shared/max-sized-box.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from 'ink'; 3 | 4 | interface MaxSizedBoxProps { 5 | maxHeight?: number; 6 | maxWidth?: number; 7 | children: React.ReactNode; 8 | } 9 | 10 | export const MaxSizedBox: React.FC = ({ 11 | maxHeight, 12 | maxWidth, 13 | children, 14 | ...props 15 | }) => { 16 | return ( 17 | 21 | {children} 22 | 23 | ); 24 | }; -------------------------------------------------------------------------------- /.github/workflows/typecheck.yml: -------------------------------------------------------------------------------- 1 | name: Type Check 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main, develop ] 8 | 9 | jobs: 10 | typecheck: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Use Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: '18' 20 | cache: 'npm' 21 | 22 | - name: Install Dependencies 23 | run: npm ci 24 | 25 | - name: Run TypeScript Type Check 26 | run: npm run typecheck 27 | -------------------------------------------------------------------------------- /src/ui/utils/code-colorizer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, Box } from 'ink'; 3 | 4 | export const colorizeCode = ( 5 | content: string, 6 | language: string | null, 7 | availableTerminalHeight?: number, 8 | terminalWidth?: number 9 | ): React.ReactNode => { 10 | // Simple plain text rendering - could be enhanced with syntax highlighting later 11 | return ( 12 | 13 | {content.split('\n').map((line, index) => ( 14 | 15 | {line} 16 | 17 | ))} 18 | 19 | ); 20 | }; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Bug Description 10 | description: Describe what happened and what you expected 11 | placeholder: What went wrong? 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | id: steps 17 | attributes: 18 | label: Steps to reproduce 19 | placeholder: | 20 | 1. 21 | 2. 22 | 3. 23 | validations: 24 | required: false -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "lib": ["ES2022"], 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": false, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "declaration": true, 13 | "sourceMap": true, 14 | "resolveJsonModule": true, 15 | "jsx": "react", 16 | "moduleResolution": "Bundler", 17 | "allowSyntheticDefaultImports": true, 18 | "noImplicitAny": false 19 | }, 20 | "include": ["src/**/*"], 21 | "exclude": ["node_modules", "dist"] 22 | } -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface ToolResult { 2 | success: boolean; 3 | output?: string; 4 | error?: string; 5 | data?: any; 6 | } 7 | 8 | export interface Tool { 9 | name: string; 10 | description: string; 11 | execute: (...args: any[]) => Promise; 12 | } 13 | 14 | export interface EditorCommand { 15 | command: 'view' | 'str_replace' | 'create' | 'insert' | 'undo_edit'; 16 | path?: string; 17 | old_str?: string; 18 | new_str?: string; 19 | content?: string; 20 | insert_line?: number; 21 | view_range?: [number, number]; 22 | replace_all?: boolean; 23 | } 24 | 25 | export interface AgentState { 26 | currentDirectory: string; 27 | editHistory: EditorCommand[]; 28 | tools: Tool[]; 29 | } 30 | 31 | export interface ConfirmationState { 32 | skipThisSession: boolean; 33 | pendingOperation: boolean; 34 | } -------------------------------------------------------------------------------- /src/ui/utils/markdown-renderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text } from 'ink'; 3 | import { marked } from 'marked'; 4 | import TerminalRenderer from 'marked-terminal'; 5 | 6 | // Configure marked to use the terminal renderer with default settings 7 | marked.setOptions({ 8 | renderer: new (TerminalRenderer as any)() 9 | }); 10 | 11 | export function MarkdownRenderer({ content }: { content: string }) { 12 | try { 13 | // Use marked.parse for synchronous parsing 14 | const result = marked.parse(content); 15 | // Handle both sync and async results 16 | const rendered = typeof result === 'string' ? result : content; 17 | return {rendered}; 18 | } catch (error) { 19 | // Fallback to plain text if markdown parsing fails 20 | console.error('Markdown rendering error:', error); 21 | return {content}; 22 | } 23 | } -------------------------------------------------------------------------------- /src/utils/custom-instructions.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as os from 'os'; 4 | 5 | export function loadCustomInstructions(workingDirectory: string = process.cwd()): string | null { 6 | try { 7 | let instructionsPath = path.join(workingDirectory, '.grok', 'GROK.md'); 8 | 9 | if (fs.existsSync(instructionsPath)) { 10 | const customInstructions = fs.readFileSync(instructionsPath, 'utf-8'); 11 | return customInstructions.trim(); 12 | } 13 | 14 | instructionsPath = path.join(os.homedir(), '.grok', 'GROK.md'); 15 | 16 | if (fs.existsSync(instructionsPath)) { 17 | const customInstructions = fs.readFileSync(instructionsPath, 'utf-8'); 18 | return customInstructions.trim(); 19 | } 20 | 21 | return null; 22 | } catch (error) { 23 | console.warn('Failed to load custom instructions:', error); 24 | return null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | name: Security Scan 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main, develop ] 8 | schedule: 9 | - cron: '0 0 * * 0' # Run weekly on Sunday at midnight 10 | 11 | jobs: 12 | security_scan: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Use Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '18' 22 | cache: 'npm' 23 | 24 | - name: Install Dependencies 25 | run: npm ci 26 | 27 | - name: Run Security Audit 28 | run: npm audit --audit-level=high 29 | 30 | - name: Scan for Secrets 31 | uses: trufflesecurity/trufflehog@v3.90.1 32 | with: 33 | path: ./ 34 | baseRef: ${{ github.event.pull_request.base.ref || github.ref }} 35 | headRef: ${{ github.event.pull_request.head.ref || github.sha }} 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [copyright holders] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission statement shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/ui/components/model-selection.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Text } from "ink"; 3 | 4 | interface ModelOption { 5 | model: string; 6 | } 7 | 8 | interface ModelSelectionProps { 9 | models: ModelOption[]; 10 | selectedIndex: number; 11 | isVisible: boolean; 12 | currentModel: string; 13 | } 14 | 15 | export function ModelSelection({ 16 | models, 17 | selectedIndex, 18 | isVisible, 19 | currentModel, 20 | }: ModelSelectionProps) { 21 | if (!isVisible) return null; 22 | 23 | return ( 24 | 25 | 26 | Select Grok Model (current: {currentModel}): 27 | 28 | {models.map((modelOption, index) => ( 29 | 30 | 34 | {modelOption.model} 35 | 36 | 37 | ))} 38 | 39 | 40 | ↑↓ navigate • Enter/Tab select • Esc cancel 41 | 42 | 43 | 44 | ); 45 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Source files (only include built dist/) 2 | src/ 3 | tsconfig.json 4 | 5 | # Lock files from other package managers 6 | yarn.lock 7 | 8 | # Development and build tools 9 | .gitignore 10 | .eslintrc* 11 | .prettierrc* 12 | jest.config.* 13 | webpack.config.* 14 | rollup.config.* 15 | 16 | # IDE and editor files 17 | .vscode/ 18 | .idea/ 19 | *.swp 20 | *.swo 21 | *~ 22 | 23 | # OS generated files 24 | .DS_Store 25 | .DS_Store? 26 | ._* 27 | .Spotlight-V100 28 | .Trashes 29 | ehthumbs.db 30 | Thumbs.db 31 | 32 | # Development dependencies logs 33 | npm-debug.log* 34 | yarn-debug.log* 35 | yarn-error.log* 36 | bun-debug.log* 37 | 38 | # Bun specific files 39 | bun.lockb 40 | bun.lock 41 | .bun/ 42 | 43 | # Environment files 44 | .env* 45 | 46 | # Test files and coverage 47 | test/ 48 | tests/ 49 | *.test.js 50 | *.test.ts 51 | *.spec.js 52 | *.spec.ts 53 | coverage/ 54 | .nyc_output/ 55 | 56 | # Documentation source (keep only README.md) 57 | docs/ 58 | *.md 59 | !README.md 60 | 61 | # Git files 62 | .git/ 63 | .gitignore 64 | 65 | # CI/CD files 66 | .github/ 67 | .gitlab-ci.yml 68 | .travis.yml 69 | circle.yml 70 | appveyor.yml 71 | 72 | # Misc development files 73 | .editorconfig 74 | .eslintcache 75 | *.tgz 76 | *.log 77 | 78 | # Include only what's needed for the package 79 | # The dist/ folder should be included (not ignored) 80 | # package.json and README.md should be included -------------------------------------------------------------------------------- /src/ui/components/mcp-status.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { Box, Text } from "ink"; 3 | import { getMCPManager } from "../../grok/tools.js"; 4 | import { MCPTool } from "../../mcp/client.js"; 5 | 6 | interface MCPStatusProps {} 7 | 8 | export function MCPStatus({}: MCPStatusProps) { 9 | const [connectedServers, setConnectedServers] = useState([]); 10 | const [availableTools, setAvailableTools] = useState([]); 11 | 12 | useEffect(() => { 13 | const updateStatus = () => { 14 | try { 15 | const manager = getMCPManager(); 16 | const servers = manager.getServers(); 17 | const tools = manager.getTools(); 18 | 19 | setConnectedServers(servers); 20 | setAvailableTools(tools); 21 | } catch (error) { 22 | // MCP manager not initialized yet 23 | setConnectedServers([]); 24 | setAvailableTools([]); 25 | } 26 | }; 27 | 28 | // Initial update with a small delay to allow MCP initialization 29 | const initialTimer = setTimeout(updateStatus, 2000); 30 | 31 | // Set up polling to check for status changes 32 | const interval = setInterval(updateStatus, 2000); 33 | 34 | return () => { 35 | clearTimeout(initialTimer); 36 | clearInterval(interval); 37 | }; 38 | }, []); 39 | 40 | if (connectedServers.length === 0) { 41 | return null; 42 | } 43 | 44 | return ( 45 | 46 | ⚒ mcps: {connectedServers.length} 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/model-config.ts: -------------------------------------------------------------------------------- 1 | import { getSettingsManager, UserSettings, ProjectSettings } from './settings-manager.js'; 2 | 3 | export interface ModelOption { 4 | model: string; 5 | } 6 | 7 | export type ModelConfig = string; 8 | 9 | // Re-export interfaces for backward compatibility 10 | export { UserSettings, ProjectSettings }; 11 | 12 | /** 13 | * Get the effective current model 14 | * Priority: project current model > user default model > system default 15 | */ 16 | export function getCurrentModel(): string { 17 | const manager = getSettingsManager(); 18 | return manager.getCurrentModel(); 19 | } 20 | 21 | /** 22 | * Load model configuration 23 | * Priority: user-settings.json models > default hardcoded 24 | */ 25 | export function loadModelConfig(): ModelOption[] { 26 | const manager = getSettingsManager(); 27 | const models = manager.getAvailableModels(); 28 | 29 | return models.map(model => ({ 30 | model: model.trim() 31 | })); 32 | } 33 | 34 | /** 35 | * Get default models list 36 | */ 37 | export function getDefaultModels(): string[] { 38 | const manager = getSettingsManager(); 39 | return manager.getAvailableModels(); 40 | } 41 | 42 | /** 43 | * Update the current model in project settings 44 | */ 45 | export function updateCurrentModel(modelName: string): void { 46 | const manager = getSettingsManager(); 47 | manager.setCurrentModel(modelName); 48 | } 49 | 50 | /** 51 | * Update the user's default model preference 52 | */ 53 | export function updateDefaultModel(modelName: string): void { 54 | const manager = getSettingsManager(); 55 | manager.updateUserSetting('defaultModel', modelName); 56 | } 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | # Ignore yarn lockfiles as we support both npm and Bun 7 | yarn.lock 8 | # Keep Bun lockfiles (both binary and text format) 9 | # bun.lockb 10 | # bun.lock 11 | 12 | # Build outputs 13 | dist/ 14 | lib/ 15 | build/ 16 | *.tsbuildinfo 17 | 18 | # Environment variables 19 | .env 20 | .env.local 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | 25 | # IDE and editor files 26 | .vscode/ 27 | .idea/ 28 | *.swp 29 | *.swo 30 | *~ 31 | 32 | # OS generated files 33 | .DS_Store 34 | .DS_Store? 35 | ._* 36 | .Spotlight-V100 37 | .Trashes 38 | ehthumbs.db 39 | Thumbs.db 40 | 41 | # Logs 42 | logs/ 43 | *.log 44 | 45 | # Runtime data 46 | pids/ 47 | *.pid 48 | *.seed 49 | *.pid.lock 50 | 51 | # Coverage directory used by tools like istanbul 52 | coverage/ 53 | .nyc_output/ 54 | 55 | # TypeScript cache 56 | *.tsbuildinfo 57 | 58 | # Optional npm cache directory 59 | .npm 60 | 61 | # Bun cache 62 | .bun/ 63 | 64 | # Optional eslint cache 65 | .eslintcache 66 | 67 | # Microbundle cache 68 | .rpt2_cache/ 69 | .rts2_cache_cjs/ 70 | .rts2_cache_es/ 71 | .rts2_cache_umd/ 72 | 73 | # Optional REPL history 74 | .node_repl_history 75 | 76 | # Output of 'npm pack' and 'bun pack' 77 | *.tgz 78 | 79 | # Yarn Integrity file 80 | .yarn-integrity 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Temporary folders 87 | tmp/ 88 | temp/ 89 | 90 | # Editor directories and files 91 | .vscode/* 92 | !.vscode/extensions.json 93 | .idea 94 | *.suo 95 | *.ntvs* 96 | *.njsproj 97 | *.sln 98 | *.sw? 99 | 100 | # Coding agents 101 | .claude/ 102 | .grok/ -------------------------------------------------------------------------------- /src/ui/components/command-suggestions.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { Box, Text } from "ink"; 3 | 4 | interface CommandSuggestion { 5 | command: string; 6 | description: string; 7 | } 8 | 9 | interface CommandSuggestionsProps { 10 | suggestions: CommandSuggestion[]; 11 | input: string; 12 | selectedIndex: number; 13 | isVisible: boolean; 14 | } 15 | 16 | export const MAX_SUGGESTIONS = 8; 17 | 18 | export function filterCommandSuggestions( 19 | suggestions: T[], 20 | input: string 21 | ): T[] { 22 | const lowerInput = input.toLowerCase(); 23 | return suggestions 24 | .filter((s) => s.command.toLowerCase().startsWith(lowerInput)) 25 | .slice(0, MAX_SUGGESTIONS); 26 | } 27 | 28 | export function CommandSuggestions({ 29 | suggestions, 30 | input, 31 | selectedIndex, 32 | isVisible, 33 | }: CommandSuggestionsProps) { 34 | if (!isVisible) return null; 35 | 36 | const filteredSuggestions = useMemo( 37 | () => filterCommandSuggestions(suggestions, input), 38 | [suggestions, input] 39 | ); 40 | 41 | return ( 42 | 43 | {filteredSuggestions.map((suggestion, index) => ( 44 | 45 | 49 | {suggestion.command} 50 | 51 | 52 | {suggestion.description} 53 | 54 | 55 | ))} 56 | 57 | 58 | ↑↓ navigate • Enter/Tab select • Esc cancel 59 | 60 | 61 | 62 | ); 63 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vibe-kit/grok-cli", 3 | "version": "0.0.34", 4 | "description": "An open-source AI agent that brings the power of Grok directly into your terminal.", 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.js", 10 | "types": "./dist/index.d.ts" 11 | } 12 | }, 13 | "bin": { 14 | "grok": "dist/index.js" 15 | }, 16 | "scripts": { 17 | "build": "tsc", 18 | "build:bun": "bun run tsc", 19 | "dev": "bun run src/index.ts", 20 | "dev:node": "tsx src/index.ts", 21 | "start": "node dist/index.js", 22 | "start:bun": "bun run dist/index.js", 23 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 24 | "typecheck": "tsc --noEmit", 25 | "install:bun": "bun install" 26 | }, 27 | "keywords": [ 28 | "cli", 29 | "agent", 30 | "text-editor", 31 | "grok", 32 | "ai" 33 | ], 34 | "author": "Your Name", 35 | "license": "MIT", 36 | "dependencies": { 37 | "@modelcontextprotocol/sdk": "^1.17.0", 38 | "axios": "^1.7.0", 39 | "cfonts": "^3.3.0", 40 | "chalk": "^5.3.0", 41 | "commander": "^12.0.0", 42 | "dotenv": "^16.4.0", 43 | "enquirer": "^2.4.1", 44 | "fs-extra": "^11.2.0", 45 | "ink": "^4.4.1", 46 | "marked": "^15.0.12", 47 | "marked-terminal": "^7.3.0", 48 | "openai": "^5.10.1", 49 | "react": "^18.3.1", 50 | "ripgrep-node": "^1.0.0", 51 | "tiktoken": "^1.0.21" 52 | }, 53 | "devDependencies": { 54 | "@types/fs-extra": "^11.0.2", 55 | "@types/node": "^20.8.0", 56 | "@types/react": "^18.3.3", 57 | "@typescript-eslint/eslint-plugin": "^8.37.0", 58 | "@typescript-eslint/parser": "^8.37.0", 59 | "eslint": "^9.31.0", 60 | "tsx": "^4.0.0", 61 | "typescript": "^5.3.3" 62 | }, 63 | "engines": { 64 | "node": ">=18.0.0" 65 | }, 66 | "preferGlobal": true 67 | } 68 | -------------------------------------------------------------------------------- /src/mcp/config.ts: -------------------------------------------------------------------------------- 1 | import { getSettingsManager } from "../utils/settings-manager.js"; 2 | import { MCPServerConfig } from "./client.js"; 3 | 4 | export interface MCPConfig { 5 | servers: MCPServerConfig[]; 6 | } 7 | 8 | /** 9 | * Load MCP configuration from project settings 10 | */ 11 | export function loadMCPConfig(): MCPConfig { 12 | const manager = getSettingsManager(); 13 | const projectSettings = manager.loadProjectSettings(); 14 | const servers = projectSettings.mcpServers ? Object.values(projectSettings.mcpServers) : []; 15 | return { servers }; 16 | } 17 | 18 | export function saveMCPConfig(config: MCPConfig): void { 19 | const manager = getSettingsManager(); 20 | const mcpServers: Record = {}; 21 | 22 | // Convert servers array to object keyed by name 23 | for (const server of config.servers) { 24 | mcpServers[server.name] = server; 25 | } 26 | 27 | manager.updateProjectSetting('mcpServers', mcpServers); 28 | } 29 | 30 | export function addMCPServer(config: MCPServerConfig): void { 31 | const manager = getSettingsManager(); 32 | const projectSettings = manager.loadProjectSettings(); 33 | const mcpServers = projectSettings.mcpServers || {}; 34 | 35 | mcpServers[config.name] = config; 36 | manager.updateProjectSetting('mcpServers', mcpServers); 37 | } 38 | 39 | export function removeMCPServer(serverName: string): void { 40 | const manager = getSettingsManager(); 41 | const projectSettings = manager.loadProjectSettings(); 42 | const mcpServers = projectSettings.mcpServers; 43 | 44 | if (mcpServers) { 45 | delete mcpServers[serverName]; 46 | manager.updateProjectSetting('mcpServers', mcpServers); 47 | } 48 | } 49 | 50 | export function getMCPServer(serverName: string): MCPServerConfig | undefined { 51 | const manager = getSettingsManager(); 52 | const projectSettings = manager.loadProjectSettings(); 53 | return projectSettings.mcpServers?.[serverName]; 54 | } 55 | 56 | // Predefined server configurations 57 | export const PREDEFINED_SERVERS: Record = {}; 58 | -------------------------------------------------------------------------------- /src/ui/components/loading-spinner.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { Box, Text } from "ink"; 3 | import { formatTokenCount } from "../../utils/token-counter.js"; 4 | 5 | interface LoadingSpinnerProps { 6 | isActive: boolean; 7 | processingTime: number; 8 | tokenCount: number; 9 | } 10 | 11 | const loadingTexts = [ 12 | "Thinking...", 13 | "Computing...", 14 | "Analyzing...", 15 | "Processing...", 16 | "Calculating...", 17 | "Interfacing...", 18 | "Optimizing...", 19 | "Synthesizing...", 20 | "Decrypting...", 21 | "Calibrating...", 22 | "Bootstrapping...", 23 | "Synchronizing...", 24 | "Compiling...", 25 | "Downloading...", 26 | ]; 27 | 28 | export function LoadingSpinner({ 29 | isActive, 30 | processingTime, 31 | tokenCount, 32 | }: LoadingSpinnerProps) { 33 | const [spinnerFrame, setSpinnerFrame] = useState(0); 34 | const [loadingTextIndex, setLoadingTextIndex] = useState(0); 35 | 36 | useEffect(() => { 37 | if (!isActive) return; 38 | 39 | const spinnerFrames = ["/", "-", "\\", "|"]; 40 | // Reduced frequency: 500ms instead of 250ms to reduce flickering on Windows 41 | const interval = setInterval(() => { 42 | setSpinnerFrame((prev) => (prev + 1) % spinnerFrames.length); 43 | }, 500); 44 | 45 | return () => clearInterval(interval); 46 | }, [isActive]); 47 | 48 | useEffect(() => { 49 | if (!isActive) return; 50 | 51 | setLoadingTextIndex(Math.floor(Math.random() * loadingTexts.length)); 52 | 53 | // Increased interval: 4s instead of 2s to reduce state changes 54 | const interval = setInterval(() => { 55 | setLoadingTextIndex(Math.floor(Math.random() * loadingTexts.length)); 56 | }, 4000); 57 | 58 | return () => clearInterval(interval); 59 | }, [isActive]); 60 | 61 | if (!isActive) return null; 62 | 63 | const spinnerFrames = ["/", "-", "\\", "|"]; 64 | 65 | return ( 66 | 67 | 68 | {spinnerFrames[spinnerFrame]} {loadingTexts[loadingTextIndex]}{" "} 69 | 70 | 71 | ({processingTime}s · ↑ {formatTokenCount(tokenCount)} tokens · esc to 72 | interrupt) 73 | 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/hooks/use-input-history.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from "react"; 2 | 3 | export interface InputHistoryHook { 4 | addToHistory: (input: string) => void; 5 | navigateHistory: (direction: "up" | "down") => string | null; 6 | getCurrentHistoryIndex: () => number; 7 | resetHistory: () => void; 8 | isNavigatingHistory: () => boolean; 9 | setOriginalInput: (input: string) => void; 10 | } 11 | 12 | export function useInputHistory(): InputHistoryHook { 13 | const [history, setHistory] = useState([]); 14 | const [currentIndex, setCurrentIndex] = useState(-1); 15 | const [originalInput, setOriginalInput] = useState(""); 16 | 17 | const addToHistory = useCallback((input: string) => { 18 | if (input.trim() && !history.includes(input.trim())) { 19 | setHistory(prev => [...prev, input.trim()]); 20 | } 21 | setCurrentIndex(-1); 22 | setOriginalInput(""); 23 | }, [history]); 24 | 25 | const navigateHistory = useCallback((direction: "up" | "down"): string | null => { 26 | if (history.length === 0) return null; 27 | 28 | let newIndex: number; 29 | 30 | if (direction === "up") { 31 | if (currentIndex === -1) { 32 | newIndex = history.length - 1; 33 | } else { 34 | newIndex = Math.max(0, currentIndex - 1); 35 | } 36 | } else { 37 | if (currentIndex === -1) { 38 | return null; 39 | } else if (currentIndex === history.length - 1) { 40 | newIndex = -1; 41 | return originalInput; 42 | } else { 43 | newIndex = Math.min(history.length - 1, currentIndex + 1); 44 | } 45 | } 46 | 47 | setCurrentIndex(newIndex); 48 | return newIndex === -1 ? originalInput : history[newIndex]; 49 | }, [history, currentIndex, originalInput]); 50 | 51 | const getCurrentHistoryIndex = useCallback(() => currentIndex, [currentIndex]); 52 | 53 | const resetHistory = useCallback(() => { 54 | setHistory([]); 55 | setCurrentIndex(-1); 56 | setOriginalInput(""); 57 | }, []); 58 | 59 | const isNavigatingHistory = useCallback(() => currentIndex !== -1, [currentIndex]); 60 | 61 | const setOriginalInputCallback = useCallback((input: string) => { 62 | if (currentIndex === -1) { 63 | setOriginalInput(input); 64 | } 65 | }, [currentIndex]); 66 | 67 | return { 68 | addToHistory, 69 | navigateHistory, 70 | getCurrentHistoryIndex, 71 | resetHistory, 72 | isNavigatingHistory, 73 | setOriginalInput: setOriginalInputCallback, 74 | }; 75 | } -------------------------------------------------------------------------------- /src/utils/token-counter.ts: -------------------------------------------------------------------------------- 1 | import { get_encoding, encoding_for_model, Tiktoken } from 'tiktoken'; 2 | 3 | export class TokenCounter { 4 | private encoder: Tiktoken; 5 | 6 | constructor(model: string = 'gpt-4') { 7 | try { 8 | // Try to get encoding for specific model 9 | this.encoder = encoding_for_model(model as any); 10 | } catch { 11 | // Fallback to cl100k_base (used by GPT-4 and most modern models) 12 | this.encoder = get_encoding('cl100k_base'); 13 | } 14 | } 15 | 16 | /** 17 | * Count tokens in a string 18 | */ 19 | countTokens(text: string): number { 20 | if (!text) return 0; 21 | return this.encoder.encode(text).length; 22 | } 23 | 24 | /** 25 | * Count tokens in messages array (for chat completions) 26 | */ 27 | countMessageTokens(messages: Array<{ role: string; content: string | null; [key: string]: any }>): number { 28 | let totalTokens = 0; 29 | 30 | for (const message of messages) { 31 | // Every message follows <|start|>{role/name}\n{content}<|end|\>\n 32 | totalTokens += 3; // Base tokens per message 33 | 34 | if (message.content && typeof message.content === 'string') { 35 | totalTokens += this.countTokens(message.content); 36 | } 37 | 38 | if (message.role) { 39 | totalTokens += this.countTokens(message.role); 40 | } 41 | 42 | // Add extra tokens for tool calls if present 43 | if (message.tool_calls) { 44 | totalTokens += this.countTokens(JSON.stringify(message.tool_calls)); 45 | } 46 | } 47 | 48 | totalTokens += 3; // Every reply is primed with <|start|>assistant<|message|> 49 | 50 | return totalTokens; 51 | } 52 | 53 | /** 54 | * Estimate tokens for streaming content 55 | * This is an approximation since we don't have the full response yet 56 | */ 57 | estimateStreamingTokens(accumulatedContent: string): number { 58 | return this.countTokens(accumulatedContent); 59 | } 60 | 61 | /** 62 | * Clean up resources 63 | */ 64 | dispose(): void { 65 | this.encoder.free(); 66 | } 67 | } 68 | 69 | /** 70 | * Format token count for display (e.g., 1.2k for 1200) 71 | */ 72 | export function formatTokenCount(count: number): string { 73 | if (count <= 999) { 74 | return count.toString(); 75 | } 76 | 77 | if (count < 1_000_000) { 78 | const k = count / 1000; 79 | return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`; 80 | } 81 | 82 | const m = count / 1_000_000; 83 | return m % 1 === 0 ? `${m}m` : `${m.toFixed(1)}m`; 84 | } 85 | 86 | /** 87 | * Create a token counter instance 88 | */ 89 | export function createTokenCounter(model?: string): TokenCounter { 90 | return new TokenCounter(model); 91 | } -------------------------------------------------------------------------------- /src/tools/bash.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import { promisify } from 'util'; 3 | import { ToolResult } from '../types/index.js'; 4 | import { ConfirmationService } from '../utils/confirmation-service.js'; 5 | 6 | const execAsync = promisify(exec); 7 | 8 | export class BashTool { 9 | private currentDirectory: string = process.cwd(); 10 | private confirmationService = ConfirmationService.getInstance(); 11 | 12 | 13 | async execute(command: string, timeout: number = 30000): Promise { 14 | try { 15 | // Check if user has already accepted bash commands for this session 16 | const sessionFlags = this.confirmationService.getSessionFlags(); 17 | if (!sessionFlags.bashCommands && !sessionFlags.allOperations) { 18 | // Request confirmation showing the command 19 | const confirmationResult = await this.confirmationService.requestConfirmation({ 20 | operation: 'Run bash command', 21 | filename: command, 22 | showVSCodeOpen: false, 23 | content: `Command: ${command}\nWorking directory: ${this.currentDirectory}` 24 | }, 'bash'); 25 | 26 | if (!confirmationResult.confirmed) { 27 | return { 28 | success: false, 29 | error: confirmationResult.feedback || 'Command execution cancelled by user' 30 | }; 31 | } 32 | } 33 | 34 | if (command.startsWith('cd ')) { 35 | const newDir = command.substring(3).trim(); 36 | try { 37 | process.chdir(newDir); 38 | this.currentDirectory = process.cwd(); 39 | return { 40 | success: true, 41 | output: `Changed directory to: ${this.currentDirectory}` 42 | }; 43 | } catch (error: any) { 44 | return { 45 | success: false, 46 | error: `Cannot change directory: ${error.message}` 47 | }; 48 | } 49 | } 50 | 51 | const { stdout, stderr } = await execAsync(command, { 52 | cwd: this.currentDirectory, 53 | timeout, 54 | maxBuffer: 1024 * 1024 55 | }); 56 | 57 | const output = stdout + (stderr ? `\nSTDERR: ${stderr}` : ''); 58 | 59 | return { 60 | success: true, 61 | output: output.trim() || 'Command executed successfully (no output)' 62 | }; 63 | } catch (error: any) { 64 | return { 65 | success: false, 66 | error: `Command failed: ${error.message}` 67 | }; 68 | } 69 | } 70 | 71 | getCurrentDirectory(): string { 72 | return this.currentDirectory; 73 | } 74 | 75 | async listFiles(directory: string = '.'): Promise { 76 | return this.execute(`ls -la ${directory}`); 77 | } 78 | 79 | async findFiles(pattern: string, directory: string = '.'): Promise { 80 | return this.execute(`find ${directory} -name "${pattern}" -type f`); 81 | } 82 | 83 | async grep(pattern: string, files: string = '.'): Promise { 84 | return this.execute(`grep -r "${pattern}" ${files}`); 85 | } 86 | } -------------------------------------------------------------------------------- /src/tools/confirmation-tool.ts: -------------------------------------------------------------------------------- 1 | import { ToolResult } from '../types/index.js'; 2 | import { ConfirmationService, ConfirmationOptions } from '../utils/confirmation-service.js'; 3 | 4 | export interface ConfirmationRequest { 5 | operation: string; 6 | filename: string; 7 | description?: string; 8 | showVSCodeOpen?: boolean; 9 | autoAccept?: boolean; 10 | } 11 | 12 | export class ConfirmationTool { 13 | private confirmationService: ConfirmationService; 14 | 15 | constructor() { 16 | this.confirmationService = ConfirmationService.getInstance(); 17 | } 18 | 19 | async requestConfirmation(request: ConfirmationRequest): Promise { 20 | try { 21 | // If autoAccept is true, skip the confirmation dialog 22 | if (request.autoAccept) { 23 | return { 24 | success: true, 25 | output: `Auto-accepted: ${request.operation}(${request.filename})${request.description ? ` - ${request.description}` : ''}` 26 | }; 27 | } 28 | 29 | const options: ConfirmationOptions = { 30 | operation: request.operation, 31 | filename: request.filename, 32 | showVSCodeOpen: request.showVSCodeOpen || false 33 | }; 34 | 35 | // Determine operation type based on operation name 36 | const operationType = request.operation.toLowerCase().includes('bash') ? 'bash' : 'file'; 37 | const result = await this.confirmationService.requestConfirmation(options, operationType); 38 | 39 | if (result.confirmed) { 40 | return { 41 | success: true, 42 | output: `User confirmed: ${request.operation}(${request.filename})${request.description ? ` - ${request.description}` : ''}${result.dontAskAgain ? ' (Don\'t ask again enabled)' : ''}` 43 | }; 44 | } else { 45 | return { 46 | success: false, 47 | error: result.feedback || `User rejected: ${request.operation}(${request.filename})` 48 | }; 49 | } 50 | } catch (error: any) { 51 | return { 52 | success: false, 53 | error: `Confirmation error: ${error.message}` 54 | }; 55 | } 56 | } 57 | 58 | async checkSessionAcceptance(): Promise { 59 | try { 60 | const sessionFlags = this.confirmationService.getSessionFlags(); 61 | // Return structured data without JSON output to avoid displaying raw JSON 62 | return { 63 | success: true, 64 | data: { 65 | fileOperationsAccepted: sessionFlags.fileOperations, 66 | bashCommandsAccepted: sessionFlags.bashCommands, 67 | allOperationsAccepted: sessionFlags.allOperations, 68 | hasAnyAcceptance: sessionFlags.fileOperations || sessionFlags.bashCommands || sessionFlags.allOperations 69 | } 70 | }; 71 | } catch (error: any) { 72 | return { 73 | success: false, 74 | error: `Error checking session acceptance: ${error.message}` 75 | }; 76 | } 77 | } 78 | 79 | resetSession(): void { 80 | this.confirmationService.resetSession(); 81 | } 82 | 83 | isPending(): boolean { 84 | return this.confirmationService.isPending(); 85 | } 86 | } -------------------------------------------------------------------------------- /src/ui/components/api-key-input.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Box, Text, useInput, useApp } from "ink"; 3 | import { GrokAgent } from "../../agent/grok-agent.js"; 4 | import { getSettingsManager } from "../../utils/settings-manager.js"; 5 | 6 | interface ApiKeyInputProps { 7 | onApiKeySet: (agent: GrokAgent) => void; 8 | } 9 | 10 | export default function ApiKeyInput({ onApiKeySet }: ApiKeyInputProps) { 11 | const [input, setInput] = useState(""); 12 | const [error, setError] = useState(""); 13 | const [isSubmitting, setIsSubmitting] = useState(false); 14 | const { exit } = useApp(); 15 | 16 | useInput((inputChar, key) => { 17 | if (isSubmitting) return; 18 | 19 | if (key.ctrl && inputChar === "c") { 20 | exit(); 21 | return; 22 | } 23 | 24 | if (key.return) { 25 | handleSubmit(); 26 | return; 27 | } 28 | 29 | 30 | if (key.backspace || key.delete) { 31 | setInput((prev) => prev.slice(0, -1)); 32 | setError(""); 33 | return; 34 | } 35 | 36 | if (inputChar && !key.ctrl && !key.meta) { 37 | setInput((prev) => prev + inputChar); 38 | setError(""); 39 | } 40 | }); 41 | 42 | 43 | const handleSubmit = async () => { 44 | if (!input.trim()) { 45 | setError("API key cannot be empty"); 46 | return; 47 | } 48 | 49 | setIsSubmitting(true); 50 | try { 51 | const apiKey = input.trim(); 52 | const agent = new GrokAgent(apiKey); 53 | 54 | // Set environment variable for current process 55 | process.env.GROK_API_KEY = apiKey; 56 | 57 | // Save to user settings 58 | try { 59 | const manager = getSettingsManager(); 60 | manager.updateUserSetting('apiKey', apiKey); 61 | console.log(`\n✅ API key saved to ~/.grok/user-settings.json`); 62 | } catch (error) { 63 | console.log('\n⚠️ Could not save API key to settings file'); 64 | console.log('API key set for current session only'); 65 | } 66 | 67 | onApiKeySet(agent); 68 | } catch (error: any) { 69 | setError("Invalid API key format"); 70 | setIsSubmitting(false); 71 | } 72 | }; 73 | 74 | const displayText = input.length > 0 ? 75 | (isSubmitting ? "*".repeat(input.length) : "*".repeat(input.length) + "█") : 76 | (isSubmitting ? " " : "█"); 77 | 78 | return ( 79 | 80 | 🔑 Grok API Key Required 81 | 82 | Please enter your Grok API key to continue: 83 | 84 | 85 | 86 | 87 | {displayText} 88 | 89 | 90 | {error ? ( 91 | 92 | ❌ {error} 93 | 94 | ) : null} 95 | 96 | 97 | • Press Enter to submit 98 | • Press Ctrl+C to exit 99 | Note: API key will be saved to ~/.grok/user-settings.json 100 | 101 | 102 | {isSubmitting ? ( 103 | 104 | 🔄 Validating API key... 105 | 106 | ) : null} 107 | 108 | ); 109 | } -------------------------------------------------------------------------------- /src/ui/components/chat-input.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Text } from "ink"; 3 | 4 | interface ChatInputProps { 5 | input: string; 6 | cursorPosition: number; 7 | isProcessing: boolean; 8 | isStreaming: boolean; 9 | } 10 | 11 | export function ChatInput({ 12 | input, 13 | cursorPosition, 14 | isProcessing, 15 | isStreaming, 16 | }: ChatInputProps) { 17 | const beforeCursor = input.slice(0, cursorPosition); 18 | const afterCursor = input.slice(cursorPosition); 19 | 20 | // Handle multiline input display 21 | const lines = input.split("\n"); 22 | const isMultiline = lines.length > 1; 23 | 24 | // Calculate cursor position across lines 25 | let currentLineIndex = 0; 26 | let currentCharIndex = 0; 27 | let totalChars = 0; 28 | 29 | for (let i = 0; i < lines.length; i++) { 30 | if (totalChars + lines[i].length >= cursorPosition) { 31 | currentLineIndex = i; 32 | currentCharIndex = cursorPosition - totalChars; 33 | break; 34 | } 35 | totalChars += lines[i].length + 1; // +1 for newline 36 | } 37 | 38 | const showCursor = !isProcessing && !isStreaming; 39 | const borderColor = isProcessing || isStreaming ? "yellow" : "blue"; 40 | const promptColor = "cyan"; 41 | 42 | // Display placeholder when input is empty 43 | const placeholderText = "Ask me anything..."; 44 | const isPlaceholder = !input; 45 | 46 | if (isMultiline) { 47 | return ( 48 | 54 | {lines.map((line, index) => { 55 | const isCurrentLine = index === currentLineIndex; 56 | const promptChar = index === 0 ? "❯" : "│"; 57 | 58 | if (isCurrentLine) { 59 | const beforeCursorInLine = line.slice(0, currentCharIndex); 60 | const cursorChar = 61 | line.slice(currentCharIndex, currentCharIndex + 1) || " "; 62 | const afterCursorInLine = line.slice(currentCharIndex + 1); 63 | 64 | return ( 65 | 66 | {promptChar} 67 | 68 | {beforeCursorInLine} 69 | {showCursor && ( 70 | 71 | {cursorChar} 72 | 73 | )} 74 | {!showCursor && cursorChar !== " " && cursorChar} 75 | {afterCursorInLine} 76 | 77 | 78 | ); 79 | } else { 80 | return ( 81 | 82 | {promptChar} 83 | {line} 84 | 85 | ); 86 | } 87 | })} 88 | 89 | ); 90 | } 91 | 92 | // Single line input box 93 | const cursorChar = input.slice(cursorPosition, cursorPosition + 1) || " "; 94 | const afterCursorText = input.slice(cursorPosition + 1); 95 | 96 | return ( 97 | 104 | 105 | 106 | {isPlaceholder ? ( 107 | <> 108 | 109 | {placeholderText} 110 | 111 | {showCursor && ( 112 | 113 | {" "} 114 | 115 | )} 116 | 117 | ) : ( 118 | 119 | {beforeCursor} 120 | {showCursor && ( 121 | 122 | {cursorChar} 123 | 124 | )} 125 | {!showCursor && cursorChar !== " " && cursorChar} 126 | {afterCursorText} 127 | 128 | )} 129 | 130 | 131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /src/grok/client.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | import type { ChatCompletionMessageParam } from "openai/resources/chat"; 3 | 4 | export type GrokMessage = ChatCompletionMessageParam; 5 | 6 | export interface GrokTool { 7 | type: "function"; 8 | function: { 9 | name: string; 10 | description: string; 11 | parameters: { 12 | type: "object"; 13 | properties: Record; 14 | required: string[]; 15 | }; 16 | }; 17 | } 18 | 19 | export interface GrokToolCall { 20 | id: string; 21 | type: "function"; 22 | function: { 23 | name: string; 24 | arguments: string; 25 | }; 26 | } 27 | 28 | export interface SearchParameters { 29 | mode?: "auto" | "on" | "off"; 30 | // sources removed - let API use default sources to avoid format issues 31 | } 32 | 33 | export interface SearchOptions { 34 | search_parameters?: SearchParameters; 35 | } 36 | 37 | export interface GrokResponse { 38 | choices: Array<{ 39 | message: { 40 | role: string; 41 | content: string | null; 42 | tool_calls?: GrokToolCall[]; 43 | }; 44 | finish_reason: string; 45 | }>; 46 | } 47 | 48 | export class GrokClient { 49 | private client: OpenAI; 50 | private currentModel: string = "grok-code-fast-1"; 51 | private defaultMaxTokens: number; 52 | 53 | constructor(apiKey: string, model?: string, baseURL?: string) { 54 | this.client = new OpenAI({ 55 | apiKey, 56 | baseURL: baseURL || process.env.GROK_BASE_URL || "https://api.x.ai/v1", 57 | timeout: 360000, 58 | }); 59 | const envMax = Number(process.env.GROK_MAX_TOKENS); 60 | this.defaultMaxTokens = Number.isFinite(envMax) && envMax > 0 ? envMax : 1536; 61 | if (model) { 62 | this.currentModel = model; 63 | } 64 | } 65 | 66 | setModel(model: string): void { 67 | this.currentModel = model; 68 | } 69 | 70 | getCurrentModel(): string { 71 | return this.currentModel; 72 | } 73 | 74 | async chat( 75 | messages: GrokMessage[], 76 | tools?: GrokTool[], 77 | model?: string, 78 | searchOptions?: SearchOptions 79 | ): Promise { 80 | try { 81 | const requestPayload: any = { 82 | model: model || this.currentModel, 83 | messages, 84 | tools: tools || [], 85 | tool_choice: tools && tools.length > 0 ? "auto" : undefined, 86 | temperature: 0.7, 87 | max_tokens: this.defaultMaxTokens, 88 | }; 89 | 90 | // Add search parameters if specified 91 | if (searchOptions?.search_parameters) { 92 | requestPayload.search_parameters = searchOptions.search_parameters; 93 | } 94 | 95 | const response = 96 | await this.client.chat.completions.create(requestPayload); 97 | 98 | return response as GrokResponse; 99 | } catch (error: any) { 100 | throw new Error(`Grok API error: ${error.message}`); 101 | } 102 | } 103 | 104 | async *chatStream( 105 | messages: GrokMessage[], 106 | tools?: GrokTool[], 107 | model?: string, 108 | searchOptions?: SearchOptions 109 | ): AsyncGenerator { 110 | try { 111 | const requestPayload: any = { 112 | model: model || this.currentModel, 113 | messages, 114 | tools: tools || [], 115 | tool_choice: tools && tools.length > 0 ? "auto" : undefined, 116 | temperature: 0.7, 117 | max_tokens: this.defaultMaxTokens, 118 | stream: true, 119 | }; 120 | 121 | // Add search parameters if specified 122 | if (searchOptions?.search_parameters) { 123 | requestPayload.search_parameters = searchOptions.search_parameters; 124 | } 125 | 126 | const stream = (await this.client.chat.completions.create( 127 | requestPayload 128 | )) as any; 129 | 130 | for await (const chunk of stream) { 131 | yield chunk; 132 | } 133 | } catch (error: any) { 134 | throw new Error(`Grok API error: ${error.message}`); 135 | } 136 | } 137 | 138 | async search( 139 | query: string, 140 | searchParameters?: SearchParameters 141 | ): Promise { 142 | const searchMessage: GrokMessage = { 143 | role: "user", 144 | content: query, 145 | }; 146 | 147 | const searchOptions: SearchOptions = { 148 | search_parameters: searchParameters || { mode: "on" }, 149 | }; 150 | 151 | return this.chat([searchMessage], [], undefined, searchOptions); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/utils/confirmation-service.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import { promisify } from "util"; 3 | import { EventEmitter } from "events"; 4 | 5 | const execAsync = promisify(exec); 6 | 7 | export interface ConfirmationOptions { 8 | operation: string; 9 | filename: string; 10 | showVSCodeOpen?: boolean; 11 | content?: string; // Content to show in confirmation dialog 12 | } 13 | 14 | export interface ConfirmationResult { 15 | confirmed: boolean; 16 | dontAskAgain?: boolean; 17 | feedback?: string; 18 | } 19 | 20 | export class ConfirmationService extends EventEmitter { 21 | private static instance: ConfirmationService; 22 | private skipConfirmationThisSession = false; 23 | private pendingConfirmation: Promise | null = null; 24 | private resolveConfirmation: ((result: ConfirmationResult) => void) | null = 25 | null; 26 | 27 | // Session flags for different operation types 28 | private sessionFlags = { 29 | fileOperations: false, 30 | bashCommands: false, 31 | allOperations: false, 32 | }; 33 | 34 | static getInstance(): ConfirmationService { 35 | if (!ConfirmationService.instance) { 36 | ConfirmationService.instance = new ConfirmationService(); 37 | } 38 | return ConfirmationService.instance; 39 | } 40 | 41 | constructor() { 42 | super(); 43 | } 44 | 45 | async requestConfirmation( 46 | options: ConfirmationOptions, 47 | operationType: "file" | "bash" = "file" 48 | ): Promise { 49 | // Check session flags 50 | if ( 51 | this.sessionFlags.allOperations || 52 | (operationType === "file" && this.sessionFlags.fileOperations) || 53 | (operationType === "bash" && this.sessionFlags.bashCommands) 54 | ) { 55 | return { confirmed: true }; 56 | } 57 | 58 | // If VS Code should be opened, try to open it 59 | if (options.showVSCodeOpen) { 60 | try { 61 | await this.openInVSCode(options.filename); 62 | } catch (error) { 63 | // If VS Code opening fails, continue without it 64 | options.showVSCodeOpen = false; 65 | } 66 | } 67 | 68 | // Create a promise that will be resolved by the UI component 69 | this.pendingConfirmation = new Promise((resolve) => { 70 | this.resolveConfirmation = resolve; 71 | }); 72 | 73 | // Emit custom event that the UI can listen to (using setImmediate to ensure the UI updates) 74 | setImmediate(() => { 75 | this.emit("confirmation-requested", options); 76 | }); 77 | 78 | const result = await this.pendingConfirmation; 79 | 80 | if (result.dontAskAgain) { 81 | // Set the appropriate session flag based on operation type 82 | if (operationType === "file") { 83 | this.sessionFlags.fileOperations = true; 84 | } else if (operationType === "bash") { 85 | this.sessionFlags.bashCommands = true; 86 | } 87 | // Could also set allOperations for global skip 88 | } 89 | 90 | return result; 91 | } 92 | 93 | confirmOperation(confirmed: boolean, dontAskAgain?: boolean): void { 94 | if (this.resolveConfirmation) { 95 | this.resolveConfirmation({ confirmed, dontAskAgain }); 96 | this.resolveConfirmation = null; 97 | this.pendingConfirmation = null; 98 | } 99 | } 100 | 101 | rejectOperation(feedback?: string): void { 102 | if (this.resolveConfirmation) { 103 | this.resolveConfirmation({ confirmed: false, feedback }); 104 | this.resolveConfirmation = null; 105 | this.pendingConfirmation = null; 106 | } 107 | } 108 | 109 | private async openInVSCode(filename: string): Promise { 110 | // Try different VS Code commands 111 | const commands = ["code", "code-insiders", "codium"]; 112 | 113 | for (const cmd of commands) { 114 | try { 115 | await execAsync(`which ${cmd}`); 116 | await execAsync(`${cmd} "${filename}"`); 117 | return; 118 | } catch (error) { 119 | // Continue to next command 120 | continue; 121 | } 122 | } 123 | 124 | throw new Error("VS Code not found"); 125 | } 126 | 127 | isPending(): boolean { 128 | return this.pendingConfirmation !== null; 129 | } 130 | 131 | resetSession(): void { 132 | this.sessionFlags = { 133 | fileOperations: false, 134 | bashCommands: false, 135 | allOperations: false, 136 | }; 137 | } 138 | 139 | getSessionFlags() { 140 | return { ...this.sessionFlags }; 141 | } 142 | 143 | setSessionFlag( 144 | flagType: "fileOperations" | "bashCommands" | "allOperations", 145 | value: boolean 146 | ) { 147 | this.sessionFlags[flagType] = value; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/tools/todo-tool.ts: -------------------------------------------------------------------------------- 1 | import { ToolResult } from '../types/index.js'; 2 | 3 | interface TodoItem { 4 | id: string; 5 | content: string; 6 | status: 'pending' | 'in_progress' | 'completed'; 7 | priority: 'high' | 'medium' | 'low'; 8 | } 9 | 10 | export class TodoTool { 11 | private todos: TodoItem[] = []; 12 | 13 | formatTodoList(): string { 14 | if (this.todos.length === 0) { 15 | return 'No todos created yet'; 16 | } 17 | 18 | const getCheckbox = (status: string): string => { 19 | switch (status) { 20 | case 'completed': 21 | return '●'; 22 | case 'in_progress': 23 | return '◐'; 24 | case 'pending': 25 | return '○'; 26 | default: 27 | return '○'; 28 | } 29 | }; 30 | 31 | const getStatusColor = (status: string): string => { 32 | switch (status) { 33 | case 'completed': 34 | return '\x1b[32m'; // Green 35 | case 'in_progress': 36 | return '\x1b[36m'; // Cyan 37 | case 'pending': 38 | return '\x1b[37m'; // White/default 39 | default: 40 | return '\x1b[0m'; // Reset 41 | } 42 | }; 43 | 44 | const reset = '\x1b[0m'; 45 | let output = ''; 46 | 47 | this.todos.forEach((todo, index) => { 48 | const checkbox = getCheckbox(todo.status); 49 | const statusColor = getStatusColor(todo.status); 50 | const strikethrough = todo.status === 'completed' ? '\x1b[9m' : ''; 51 | const indent = index === 0 ? '' : ' '; 52 | 53 | output += `${indent}${statusColor}${strikethrough}${checkbox} ${todo.content}${reset}\n`; 54 | }); 55 | 56 | return output; 57 | } 58 | 59 | async createTodoList(todos: TodoItem[]): Promise { 60 | try { 61 | // Validate todos 62 | for (const todo of todos) { 63 | if (!todo.id || !todo.content || !todo.status || !todo.priority) { 64 | return { 65 | success: false, 66 | error: 'Each todo must have id, content, status, and priority fields' 67 | }; 68 | } 69 | 70 | if (!['pending', 'in_progress', 'completed'].includes(todo.status)) { 71 | return { 72 | success: false, 73 | error: `Invalid status: ${todo.status}. Must be pending, in_progress, or completed` 74 | }; 75 | } 76 | 77 | if (!['high', 'medium', 'low'].includes(todo.priority)) { 78 | return { 79 | success: false, 80 | error: `Invalid priority: ${todo.priority}. Must be high, medium, or low` 81 | }; 82 | } 83 | } 84 | 85 | this.todos = todos; 86 | 87 | return { 88 | success: true, 89 | output: this.formatTodoList() 90 | }; 91 | } catch (error) { 92 | return { 93 | success: false, 94 | error: `Error creating todo list: ${error instanceof Error ? error.message : String(error)}` 95 | }; 96 | } 97 | } 98 | 99 | async updateTodoList(updates: { id: string; status?: string; content?: string; priority?: string }[]): Promise { 100 | try { 101 | const updatedIds: string[] = []; 102 | 103 | for (const update of updates) { 104 | const todoIndex = this.todos.findIndex(t => t.id === update.id); 105 | 106 | if (todoIndex === -1) { 107 | return { 108 | success: false, 109 | error: `Todo with id ${update.id} not found` 110 | }; 111 | } 112 | 113 | const todo = this.todos[todoIndex]; 114 | 115 | if (update.status && !['pending', 'in_progress', 'completed'].includes(update.status)) { 116 | return { 117 | success: false, 118 | error: `Invalid status: ${update.status}. Must be pending, in_progress, or completed` 119 | }; 120 | } 121 | 122 | if (update.priority && !['high', 'medium', 'low'].includes(update.priority)) { 123 | return { 124 | success: false, 125 | error: `Invalid priority: ${update.priority}. Must be high, medium, or low` 126 | }; 127 | } 128 | 129 | if (update.status) todo.status = update.status as any; 130 | if (update.content) todo.content = update.content; 131 | if (update.priority) todo.priority = update.priority as any; 132 | 133 | updatedIds.push(update.id); 134 | } 135 | 136 | return { 137 | success: true, 138 | output: this.formatTodoList() 139 | }; 140 | } catch (error) { 141 | return { 142 | success: false, 143 | error: `Error updating todo list: ${error instanceof Error ? error.message : String(error)}` 144 | }; 145 | } 146 | } 147 | 148 | async viewTodoList(): Promise { 149 | return { 150 | success: true, 151 | output: this.formatTodoList() 152 | }; 153 | } 154 | } -------------------------------------------------------------------------------- /src/agent/index.ts: -------------------------------------------------------------------------------- 1 | import { TextEditorTool, BashTool } from '../tools/index.js'; 2 | import { ToolResult, AgentState } from '../types/index.js'; 3 | 4 | export class Agent { 5 | private textEditor: TextEditorTool; 6 | private bash: BashTool; 7 | private state: AgentState; 8 | 9 | constructor() { 10 | this.textEditor = new TextEditorTool(); 11 | this.bash = new BashTool(); 12 | this.state = { 13 | currentDirectory: process.cwd(), 14 | editHistory: [], 15 | tools: [] 16 | }; 17 | } 18 | 19 | async processCommand(input: string): Promise { 20 | const trimmedInput = input.trim(); 21 | 22 | if (trimmedInput.startsWith('view ')) { 23 | const args = this.parseViewCommand(trimmedInput); 24 | return this.textEditor.view(args.path, args.range); 25 | } 26 | 27 | if (trimmedInput.startsWith('str_replace ')) { 28 | const args = this.parseStrReplaceCommand(trimmedInput); 29 | if (!args) { 30 | return { success: false, error: 'Invalid str_replace command format' }; 31 | } 32 | return this.textEditor.strReplace(args.path, args.oldStr, args.newStr); 33 | } 34 | 35 | if (trimmedInput.startsWith('create ')) { 36 | const args = this.parseCreateCommand(trimmedInput); 37 | if (!args) { 38 | return { success: false, error: 'Invalid create command format' }; 39 | } 40 | return this.textEditor.create(args.path, args.content); 41 | } 42 | 43 | if (trimmedInput.startsWith('insert ')) { 44 | const args = this.parseInsertCommand(trimmedInput); 45 | if (!args) { 46 | return { success: false, error: 'Invalid insert command format' }; 47 | } 48 | return this.textEditor.insert(args.path, args.line, args.content); 49 | } 50 | 51 | if (trimmedInput === 'undo_edit') { 52 | return this.textEditor.undoEdit(); 53 | } 54 | 55 | if (trimmedInput.startsWith('bash ') || trimmedInput.startsWith('$ ')) { 56 | const command = trimmedInput.startsWith('bash ') 57 | ? trimmedInput.substring(5) 58 | : trimmedInput.substring(2); 59 | return this.bash.execute(command); 60 | } 61 | 62 | if (trimmedInput === 'pwd') { 63 | return { 64 | success: true, 65 | output: this.bash.getCurrentDirectory() 66 | }; 67 | } 68 | 69 | if (trimmedInput === 'history') { 70 | const history = this.textEditor.getEditHistory(); 71 | return { 72 | success: true, 73 | output: history.length > 0 74 | ? JSON.stringify(history, null, 2) 75 | : 'No edit history' 76 | }; 77 | } 78 | 79 | if (trimmedInput === 'help') { 80 | return this.getHelp(); 81 | } 82 | 83 | return this.bash.execute(trimmedInput); 84 | } 85 | 86 | private parseViewCommand(input: string): { path: string; range?: [number, number] } { 87 | const parts = input.split(' '); 88 | const path = parts[1]; 89 | 90 | if (parts.length > 2) { 91 | const rangePart = parts[2]; 92 | if (rangePart.includes('-')) { 93 | const [start, end] = rangePart.split('-').map(Number); 94 | return { path, range: [start, end] }; 95 | } 96 | } 97 | 98 | return { path }; 99 | } 100 | 101 | private parseStrReplaceCommand(input: string): { path: string; oldStr: string; newStr: string } | null { 102 | const match = input.match(/str_replace\s+(\S+)\s+"([^"]+)"\s+"([^"]*)"/); 103 | if (!match) return null; 104 | 105 | return { 106 | path: match[1], 107 | oldStr: match[2], 108 | newStr: match[3] 109 | }; 110 | } 111 | 112 | private parseCreateCommand(input: string): { path: string; content: string } | null { 113 | const match = input.match(/create\s+(\S+)\s+"([^"]*)"/); 114 | if (!match) return null; 115 | 116 | return { 117 | path: match[1], 118 | content: match[2] 119 | }; 120 | } 121 | 122 | private parseInsertCommand(input: string): { path: string; line: number; content: string } | null { 123 | const match = input.match(/insert\s+(\S+)\s+(\d+)\s+"([^"]*)"/); 124 | if (!match) return null; 125 | 126 | return { 127 | path: match[1], 128 | line: parseInt(match[2]), 129 | content: match[3] 130 | }; 131 | } 132 | 133 | private getHelp(): ToolResult { 134 | return { 135 | success: true, 136 | output: `Available commands: 137 | view [start-end] - View file contents or directory 138 | str_replace "old" "new" - Replace text in file 139 | create "content" - Create new file with content 140 | insert "text" - Insert text at specific line 141 | undo_edit - Undo last edit operation 142 | bash - Execute bash command 143 | $ - Execute bash command (shorthand) 144 | pwd - Show current directory 145 | history - Show edit history 146 | help - Show this help message` 147 | }; 148 | } 149 | 150 | getCurrentState(): AgentState { 151 | return { 152 | ...this.state, 153 | currentDirectory: this.bash.getCurrentDirectory(), 154 | editHistory: this.textEditor.getEditHistory() 155 | }; 156 | } 157 | } -------------------------------------------------------------------------------- /src/ui/components/confirmation-dialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Box, Text, useInput } from "ink"; 3 | import { DiffRenderer } from "./diff-renderer.js"; 4 | 5 | interface ConfirmationDialogProps { 6 | operation: string; 7 | filename: string; 8 | onConfirm: (dontAskAgain?: boolean) => void; 9 | onReject: (feedback?: string) => void; 10 | showVSCodeOpen?: boolean; 11 | content?: string; // Optional content to show (file content or command) 12 | } 13 | 14 | export default function ConfirmationDialog({ 15 | operation, 16 | filename, 17 | onConfirm, 18 | onReject, 19 | showVSCodeOpen = false, 20 | content, 21 | }: ConfirmationDialogProps) { 22 | const [selectedOption, setSelectedOption] = useState(0); 23 | const [feedbackMode, setFeedbackMode] = useState(false); 24 | const [feedback, setFeedback] = useState(""); 25 | 26 | const options = [ 27 | "Yes", 28 | "Yes, and don't ask again this session", 29 | "No", 30 | "No, with feedback", 31 | ]; 32 | 33 | useInput((input, key) => { 34 | if (feedbackMode) { 35 | if (key.return) { 36 | onReject(feedback.trim()); 37 | return; 38 | } 39 | if (key.backspace || key.delete) { 40 | setFeedback((prev) => prev.slice(0, -1)); 41 | return; 42 | } 43 | if (input && !key.ctrl && !key.meta) { 44 | setFeedback((prev) => prev + input); 45 | } 46 | return; 47 | } 48 | 49 | if (key.upArrow || (key.shift && key.tab)) { 50 | setSelectedOption((prev) => (prev > 0 ? prev - 1 : options.length - 1)); 51 | return; 52 | } 53 | 54 | if (key.downArrow || key.tab) { 55 | setSelectedOption((prev) => (prev + 1) % options.length); 56 | return; 57 | } 58 | 59 | if (key.return) { 60 | if (selectedOption === 0) { 61 | onConfirm(false); 62 | } else if (selectedOption === 1) { 63 | onConfirm(true); 64 | } else if (selectedOption === 2) { 65 | onReject("Operation cancelled by user"); 66 | } else { 67 | setFeedbackMode(true); 68 | } 69 | return; 70 | } 71 | 72 | if (key.escape) { 73 | if (feedbackMode) { 74 | setFeedbackMode(false); 75 | setFeedback(""); 76 | } else { 77 | // Cancel the confirmation when escape is pressed from main confirmation 78 | onReject("Operation cancelled by user (pressed Escape)"); 79 | } 80 | return; 81 | } 82 | }); 83 | 84 | if (feedbackMode) { 85 | return ( 86 | 87 | 88 | 89 | Type your feedback and press Enter, or press Escape to go back. 90 | 91 | 92 | 93 | 99 | 100 | 101 | {feedback} 102 | 103 | 104 | 105 | 106 | ); 107 | } 108 | 109 | return ( 110 | 111 | {/* Tool use header - styled like chat history */} 112 | 113 | 114 | 115 | 116 | {" "} 117 | {operation}({filename}) 118 | 119 | 120 | 121 | 122 | 123 | ⎿ Requesting user confirmation 124 | 125 | {showVSCodeOpen && ( 126 | 127 | ⎿ Opened changes in Visual Studio Code ⧉ 128 | 129 | )} 130 | 131 | {/* Show content preview if provided */} 132 | {content && ( 133 | <> 134 | ⎿ {content.split('\n')[0]} 135 | 136 | 141 | 142 | 143 | )} 144 | 145 | 146 | {/* Confirmation options */} 147 | 148 | 149 | Do you want to proceed with this operation? 150 | 151 | 152 | 153 | {options.map((option, index) => ( 154 | 155 | 159 | {index + 1}. {option} 160 | 161 | 162 | ))} 163 | 164 | 165 | 166 | 167 | ↑↓ navigate • Enter select • Esc cancel 168 | 169 | 170 | 171 | 172 | ); 173 | } 174 | -------------------------------------------------------------------------------- /src/ui/app.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Box, Text, useInput } from 'ink'; 3 | import { Agent } from '../agent/index.js'; 4 | import { ToolResult } from '../types/index.js'; 5 | import { ConfirmationService, ConfirmationOptions } from '../utils/confirmation-service.js'; 6 | import ConfirmationDialog from './components/confirmation-dialog.js'; 7 | import chalk from 'chalk'; 8 | 9 | interface Props { 10 | agent: Agent; 11 | } 12 | 13 | export default function App({ agent }: Props) { 14 | const [input, setInput] = useState(''); 15 | const [history, setHistory] = useState>([]); 16 | const [isProcessing, setIsProcessing] = useState(false); 17 | const [confirmationOptions, setConfirmationOptions] = useState(null); 18 | // Removed useApp().exit - using process.exit(0) instead for better terminal handling 19 | 20 | const confirmationService = ConfirmationService.getInstance(); 21 | 22 | useEffect(() => { 23 | const handleConfirmationRequest = (options: ConfirmationOptions) => { 24 | setConfirmationOptions(options); 25 | }; 26 | 27 | confirmationService.on('confirmation-requested', handleConfirmationRequest); 28 | 29 | return () => { 30 | confirmationService.off('confirmation-requested', handleConfirmationRequest); 31 | }; 32 | }, [confirmationService]); 33 | 34 | // Reset confirmation service session on app start 35 | useEffect(() => { 36 | confirmationService.resetSession(); 37 | }, []); 38 | 39 | useInput(async (inputChar: string, key: any) => { 40 | // If confirmation dialog is open, don't handle normal input 41 | if (confirmationOptions) { 42 | return; 43 | } 44 | if (key.ctrl && inputChar === 'c') { 45 | process.exit(0); 46 | return; 47 | } 48 | 49 | if (key.return) { 50 | if (input.trim() === 'exit' || input.trim() === 'quit') { 51 | process.exit(0); 52 | return; 53 | } 54 | 55 | if (input.trim()) { 56 | setIsProcessing(true); 57 | const result = await agent.processCommand(input.trim()); 58 | setHistory(prev => [...prev, { command: input.trim(), result }]); 59 | setInput(''); 60 | setIsProcessing(false); 61 | } 62 | return; 63 | } 64 | 65 | if (key.backspace || key.delete) { 66 | setInput(prev => prev.slice(0, -1)); 67 | return; 68 | } 69 | 70 | if (inputChar && !key.ctrl && !key.meta) { 71 | setInput(prev => prev + inputChar); 72 | } 73 | }); 74 | 75 | const renderResult = (result: ToolResult) => { 76 | if (result.success) { 77 | return ( 78 | 79 | ✓ Success 80 | {result.output && ( 81 | 82 | {result.output} 83 | 84 | )} 85 | 86 | ); 87 | } else { 88 | return ( 89 | 90 | ✗ Error 91 | {result.error && ( 92 | 93 | {result.error} 94 | 95 | )} 96 | 97 | ); 98 | } 99 | }; 100 | 101 | const handleConfirmation = (dontAskAgain?: boolean) => { 102 | confirmationService.confirmOperation(true, dontAskAgain); 103 | setConfirmationOptions(null); 104 | }; 105 | 106 | const handleRejection = (feedback?: string) => { 107 | confirmationService.rejectOperation(feedback); 108 | setConfirmationOptions(null); 109 | }; 110 | 111 | if (confirmationOptions) { 112 | return ( 113 | 120 | ); 121 | } 122 | 123 | return ( 124 | 125 | 126 | 127 | 🔧 Grok CLI - Text Editor Agent 128 | 129 | 130 | 131 | 132 | 133 | Available commands: view, str_replace, create, insert, undo_edit, bash, help 134 | 135 | 136 | Type 'help' for detailed usage, 'exit' or Ctrl+C to quit 137 | 138 | 139 | 140 | 141 | {history.slice(-10).map((entry, index) => ( 142 | 143 | 144 | $ 145 | {entry.command} 146 | 147 | {renderResult(entry.result)} 148 | 149 | ))} 150 | 151 | 152 | 153 | $ 154 | 155 | {input} 156 | {!isProcessing && } 157 | 158 | {isProcessing && (processing...)} 159 | 160 | 161 | ); 162 | } -------------------------------------------------------------------------------- /src/mcp/client.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; 3 | import { EventEmitter } from "events"; 4 | import { createTransport, MCPTransport, TransportType, TransportConfig } from "./transports.js"; 5 | 6 | export interface MCPServerConfig { 7 | name: string; 8 | transport: TransportConfig; 9 | // Legacy support for stdio-only configs 10 | command?: string; 11 | args?: string[]; 12 | env?: Record; 13 | } 14 | 15 | export interface MCPTool { 16 | name: string; 17 | description: string; 18 | inputSchema: any; 19 | serverName: string; 20 | } 21 | 22 | export class MCPManager extends EventEmitter { 23 | private clients: Map = new Map(); 24 | private transports: Map = new Map(); 25 | private tools: Map = new Map(); 26 | 27 | async addServer(config: MCPServerConfig): Promise { 28 | try { 29 | // Handle legacy stdio-only configuration 30 | let transportConfig = config.transport; 31 | if (!transportConfig && config.command) { 32 | transportConfig = { 33 | type: 'stdio', 34 | command: config.command, 35 | args: config.args, 36 | env: config.env 37 | }; 38 | } 39 | 40 | if (!transportConfig) { 41 | throw new Error('Transport configuration is required'); 42 | } 43 | 44 | // Create transport 45 | const transport = createTransport(transportConfig); 46 | this.transports.set(config.name, transport); 47 | 48 | // Create client 49 | const client = new Client( 50 | { 51 | name: "grok-cli", 52 | version: "1.0.0" 53 | }, 54 | { 55 | capabilities: { 56 | tools: {} 57 | } 58 | } 59 | ); 60 | 61 | this.clients.set(config.name, client); 62 | 63 | // Connect 64 | const sdkTransport = await transport.connect(); 65 | await client.connect(sdkTransport); 66 | 67 | // List available tools 68 | const toolsResult = await client.listTools(); 69 | 70 | // Register tools 71 | for (const tool of toolsResult.tools) { 72 | const mcpTool: MCPTool = { 73 | name: `mcp__${config.name}__${tool.name}`, 74 | description: tool.description || `Tool from ${config.name} server`, 75 | inputSchema: tool.inputSchema, 76 | serverName: config.name 77 | }; 78 | this.tools.set(mcpTool.name, mcpTool); 79 | } 80 | 81 | this.emit('serverAdded', config.name, toolsResult.tools.length); 82 | } catch (error) { 83 | this.emit('serverError', config.name, error); 84 | throw error; 85 | } 86 | } 87 | 88 | async removeServer(serverName: string): Promise { 89 | // Remove tools 90 | for (const [toolName, tool] of this.tools.entries()) { 91 | if (tool.serverName === serverName) { 92 | this.tools.delete(toolName); 93 | } 94 | } 95 | 96 | // Disconnect client 97 | const client = this.clients.get(serverName); 98 | if (client) { 99 | await client.close(); 100 | this.clients.delete(serverName); 101 | } 102 | 103 | // Close transport 104 | const transport = this.transports.get(serverName); 105 | if (transport) { 106 | await transport.disconnect(); 107 | this.transports.delete(serverName); 108 | } 109 | 110 | this.emit('serverRemoved', serverName); 111 | } 112 | 113 | async callTool(toolName: string, arguments_: any): Promise { 114 | const tool = this.tools.get(toolName); 115 | if (!tool) { 116 | throw new Error(`Tool ${toolName} not found`); 117 | } 118 | 119 | const client = this.clients.get(tool.serverName); 120 | if (!client) { 121 | throw new Error(`Server ${tool.serverName} not connected`); 122 | } 123 | 124 | // Extract the original tool name (remove mcp__servername__ prefix) 125 | const originalToolName = toolName.replace(`mcp__${tool.serverName}__`, ''); 126 | 127 | return await client.callTool({ 128 | name: originalToolName, 129 | arguments: arguments_ 130 | }); 131 | } 132 | 133 | getTools(): MCPTool[] { 134 | return Array.from(this.tools.values()); 135 | } 136 | 137 | getServers(): string[] { 138 | return Array.from(this.clients.keys()); 139 | } 140 | 141 | async shutdown(): Promise { 142 | const serverNames = Array.from(this.clients.keys()); 143 | await Promise.all(serverNames.map(name => this.removeServer(name))); 144 | } 145 | 146 | getTransportType(serverName: string): TransportType | undefined { 147 | const transport = this.transports.get(serverName); 148 | return transport?.getType(); 149 | } 150 | 151 | async ensureServersInitialized(): Promise { 152 | if (this.clients.size > 0) { 153 | return; // Already initialized 154 | } 155 | 156 | const { loadMCPConfig } = await import('../mcp/config'); 157 | const config = loadMCPConfig(); 158 | 159 | // Initialize servers in parallel to avoid blocking 160 | const initPromises = config.servers.map(async (serverConfig) => { 161 | try { 162 | await this.addServer(serverConfig); 163 | } catch (error) { 164 | console.warn(`Failed to initialize MCP server ${serverConfig.name}:`, error); 165 | } 166 | }); 167 | 168 | await Promise.all(initPromises); 169 | } 170 | } -------------------------------------------------------------------------------- /src/utils/text-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Text manipulation utilities for terminal input handling 3 | * Inspired by Gemini CLI's text processing capabilities 4 | */ 5 | 6 | export interface TextPosition { 7 | index: number; 8 | line: number; 9 | column: number; 10 | } 11 | 12 | export interface TextSelection { 13 | start: number; 14 | end: number; 15 | } 16 | 17 | /** 18 | * Check if a character is a word boundary 19 | */ 20 | export function isWordBoundary(char: string | undefined): boolean { 21 | if (!char) return true; 22 | return /\s/.test(char) || /[^\w]/.test(char); 23 | } 24 | 25 | /** 26 | * Find the start of the current word at the given position 27 | */ 28 | export function findWordStart(text: string, position: number): number { 29 | if (position <= 0) return 0; 30 | 31 | let pos = position - 1; 32 | while (pos > 0 && !isWordBoundary(text[pos])) { 33 | pos--; 34 | } 35 | 36 | // If we stopped at a word boundary, move forward to the actual word start 37 | if (pos > 0 && isWordBoundary(text[pos])) { 38 | pos++; 39 | } 40 | 41 | return pos; 42 | } 43 | 44 | /** 45 | * Find the end of the current word at the given position 46 | */ 47 | export function findWordEnd(text: string, position: number): number { 48 | if (position >= text.length) return text.length; 49 | 50 | let pos = position; 51 | while (pos < text.length && !isWordBoundary(text[pos])) { 52 | pos++; 53 | } 54 | 55 | return pos; 56 | } 57 | 58 | /** 59 | * Move cursor to the previous word boundary 60 | */ 61 | export function moveToPreviousWord(text: string, position: number): number { 62 | if (position <= 0) return 0; 63 | 64 | let pos = position - 1; 65 | 66 | // Skip whitespace 67 | while (pos > 0 && isWordBoundary(text[pos])) { 68 | pos--; 69 | } 70 | 71 | // Find start of the word 72 | while (pos > 0 && !isWordBoundary(text[pos - 1])) { 73 | pos--; 74 | } 75 | 76 | return pos; 77 | } 78 | 79 | /** 80 | * Move cursor to the next word boundary 81 | */ 82 | export function moveToNextWord(text: string, position: number): number { 83 | if (position >= text.length) return text.length; 84 | 85 | let pos = position; 86 | 87 | // Skip current word 88 | while (pos < text.length && !isWordBoundary(text[pos])) { 89 | pos++; 90 | } 91 | 92 | // Skip whitespace 93 | while (pos < text.length && isWordBoundary(text[pos])) { 94 | pos++; 95 | } 96 | 97 | return pos; 98 | } 99 | 100 | /** 101 | * Delete the word before the cursor 102 | */ 103 | export function deleteWordBefore(text: string, position: number): { text: string; position: number } { 104 | const wordStart = moveToPreviousWord(text, position); 105 | const newText = text.slice(0, wordStart) + text.slice(position); 106 | 107 | return { 108 | text: newText, 109 | position: wordStart, 110 | }; 111 | } 112 | 113 | /** 114 | * Delete the word after the cursor 115 | */ 116 | export function deleteWordAfter(text: string, position: number): { text: string; position: number } { 117 | const wordEnd = moveToNextWord(text, position); 118 | const newText = text.slice(0, position) + text.slice(wordEnd); 119 | 120 | return { 121 | text: newText, 122 | position, 123 | }; 124 | } 125 | 126 | /** 127 | * Get the current line and column from text position 128 | */ 129 | export function getTextPosition(text: string, index: number): TextPosition { 130 | const lines = text.slice(0, index).split('\n'); 131 | return { 132 | index, 133 | line: lines.length - 1, 134 | column: lines[lines.length - 1].length, 135 | }; 136 | } 137 | 138 | /** 139 | * Move to the beginning of the current line 140 | */ 141 | export function moveToLineStart(text: string, position: number): number { 142 | const beforeCursor = text.slice(0, position); 143 | const lastNewlineIndex = beforeCursor.lastIndexOf('\n'); 144 | return lastNewlineIndex === -1 ? 0 : lastNewlineIndex + 1; 145 | } 146 | 147 | /** 148 | * Move to the end of the current line 149 | */ 150 | export function moveToLineEnd(text: string, position: number): number { 151 | const afterCursor = text.slice(position); 152 | const nextNewlineIndex = afterCursor.indexOf('\n'); 153 | return nextNewlineIndex === -1 ? text.length : position + nextNewlineIndex; 154 | } 155 | 156 | /** 157 | * Handle proper Unicode-aware character deletion 158 | */ 159 | export function deleteCharBefore(text: string, position: number): { text: string; position: number } { 160 | if (position <= 0) { 161 | return { text, position }; 162 | } 163 | 164 | // Handle surrogate pairs and combining characters 165 | let deleteCount = 1; 166 | const charBefore = text.charAt(position - 1); 167 | 168 | // Check for high surrogate (first part of surrogate pair) 169 | if (position >= 2) { 170 | const charBeforeBefore = text.charAt(position - 2); 171 | if (charBeforeBefore >= '\uD800' && charBeforeBefore <= '\uDBFF' && 172 | charBefore >= '\uDC00' && charBefore <= '\uDFFF') { 173 | deleteCount = 2; 174 | } 175 | } 176 | 177 | const newText = text.slice(0, position - deleteCount) + text.slice(position); 178 | return { 179 | text: newText, 180 | position: position - deleteCount, 181 | }; 182 | } 183 | 184 | /** 185 | * Handle proper Unicode-aware character deletion forward 186 | */ 187 | export function deleteCharAfter(text: string, position: number): { text: string; position: number } { 188 | if (position >= text.length) { 189 | return { text, position }; 190 | } 191 | 192 | // Handle surrogate pairs and combining characters 193 | let deleteCount = 1; 194 | const charAfter = text.charAt(position); 195 | 196 | // Check for high surrogate (first part of surrogate pair) 197 | if (position + 1 < text.length) { 198 | const charAfterAfter = text.charAt(position + 1); 199 | if (charAfter >= '\uD800' && charAfter <= '\uDBFF' && 200 | charAfterAfter >= '\uDC00' && charAfterAfter <= '\uDFFF') { 201 | deleteCount = 2; 202 | } 203 | } 204 | 205 | const newText = text.slice(0, position) + text.slice(position + deleteCount); 206 | return { 207 | text: newText, 208 | position, 209 | }; 210 | } 211 | 212 | /** 213 | * Insert text at the given position with proper Unicode handling 214 | */ 215 | export function insertText(text: string, position: number, insert: string): { text: string; position: number } { 216 | const newText = text.slice(0, position) + insert + text.slice(position); 217 | return { 218 | text: newText, 219 | position: position + insert.length, 220 | }; 221 | } -------------------------------------------------------------------------------- /src/mcp/transports.ts: -------------------------------------------------------------------------------- 1 | import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; 2 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; 3 | import { ChildProcess, spawn } from "child_process"; 4 | import { EventEmitter } from "events"; 5 | import axios, { AxiosInstance } from "axios"; 6 | 7 | export type TransportType = 'stdio' | 'http' | 'sse' | 'streamable_http'; 8 | 9 | export interface TransportConfig { 10 | type: TransportType; 11 | command?: string; 12 | args?: string[]; 13 | env?: Record; 14 | url?: string; 15 | headers?: Record; 16 | } 17 | 18 | export interface MCPTransport { 19 | connect(): Promise; 20 | disconnect(): Promise; 21 | getType(): TransportType; 22 | } 23 | 24 | export class StdioTransport implements MCPTransport { 25 | private transport?: StdioClientTransport; 26 | private process?: ChildProcess; 27 | 28 | constructor(private config: TransportConfig) { 29 | if (!config.command) { 30 | throw new Error('Command is required for stdio transport'); 31 | } 32 | } 33 | 34 | async connect(): Promise { 35 | // Create transport with environment variables to suppress verbose output 36 | const env = { 37 | ...process.env, 38 | ...this.config.env, 39 | // Try to suppress verbose output from mcp-remote 40 | MCP_REMOTE_QUIET: '1', 41 | MCP_REMOTE_SILENT: '1', 42 | DEBUG: '', 43 | NODE_ENV: 'production' 44 | }; 45 | 46 | this.transport = new StdioClientTransport({ 47 | command: this.config.command!, 48 | args: this.config.args || [], 49 | env 50 | }); 51 | 52 | return this.transport; 53 | } 54 | 55 | async disconnect(): Promise { 56 | if (this.transport) { 57 | await this.transport.close(); 58 | this.transport = undefined; 59 | } 60 | 61 | if (this.process) { 62 | this.process.kill(); 63 | this.process = undefined; 64 | } 65 | } 66 | 67 | getType(): TransportType { 68 | return 'stdio'; 69 | } 70 | } 71 | 72 | export class HttpTransport extends EventEmitter implements MCPTransport { 73 | private client?: AxiosInstance; 74 | private connected = false; 75 | 76 | constructor(private config: TransportConfig) { 77 | super(); 78 | if (!config.url) { 79 | throw new Error('URL is required for HTTP transport'); 80 | } 81 | } 82 | 83 | async connect(): Promise { 84 | this.client = axios.create({ 85 | baseURL: this.config.url, 86 | headers: { 87 | 'Content-Type': 'application/json', 88 | ...this.config.headers 89 | } 90 | }); 91 | 92 | // Test connection 93 | try { 94 | await this.client.get('/health'); 95 | this.connected = true; 96 | } catch (error) { 97 | // If health endpoint doesn't exist, try a basic request 98 | this.connected = true; 99 | } 100 | 101 | return new HttpClientTransport(this.client); 102 | } 103 | 104 | async disconnect(): Promise { 105 | this.connected = false; 106 | this.client = undefined; 107 | } 108 | 109 | getType(): TransportType { 110 | return 'http'; 111 | } 112 | } 113 | 114 | export class SSETransport extends EventEmitter implements MCPTransport { 115 | private connected = false; 116 | 117 | constructor(private config: TransportConfig) { 118 | super(); 119 | if (!config.url) { 120 | throw new Error('URL is required for SSE transport'); 121 | } 122 | } 123 | 124 | async connect(): Promise { 125 | return new Promise((resolve, reject) => { 126 | try { 127 | // For Node.js environment, we'll use a simple HTTP-based approach 128 | // In a real implementation, you'd use a proper SSE library like 'eventsource' 129 | this.connected = true; 130 | resolve(new SSEClientTransport(this.config.url!)); 131 | } catch (error) { 132 | reject(error); 133 | } 134 | }); 135 | } 136 | 137 | async disconnect(): Promise { 138 | this.connected = false; 139 | } 140 | 141 | getType(): TransportType { 142 | return 'sse'; 143 | } 144 | } 145 | 146 | // Custom HTTP Transport implementation 147 | class HttpClientTransport extends EventEmitter implements Transport { 148 | constructor(private client: AxiosInstance) { 149 | super(); 150 | } 151 | 152 | async start(): Promise { 153 | // HTTP transport is connection-less, so we're always "started" 154 | } 155 | 156 | async close(): Promise { 157 | // Nothing to close for HTTP transport 158 | } 159 | 160 | async send(message: any): Promise { 161 | try { 162 | const response = await this.client.post('/rpc', message); 163 | return response.data; 164 | } catch (error) { 165 | throw new Error(`HTTP transport error: ${error}`); 166 | } 167 | } 168 | } 169 | 170 | // Custom SSE Transport implementation 171 | class SSEClientTransport extends EventEmitter implements Transport { 172 | constructor(private url: string) { 173 | super(); 174 | } 175 | 176 | async start(): Promise { 177 | // SSE transport is event-driven, so we're always "started" 178 | } 179 | 180 | async close(): Promise { 181 | // Nothing to close for basic SSE transport 182 | } 183 | 184 | async send(message: any): Promise { 185 | // For bidirectional communication over SSE, we typically use HTTP POST 186 | // for sending messages and SSE for receiving 187 | try { 188 | const response = await axios.post(this.url.replace('/sse', '/rpc'), message, { 189 | headers: { 'Content-Type': 'application/json' } 190 | }); 191 | return response.data; 192 | } catch (error) { 193 | throw new Error(`SSE transport error: ${error}`); 194 | } 195 | } 196 | } 197 | 198 | export class StreamableHttpTransport extends EventEmitter implements MCPTransport { 199 | private connected = false; 200 | 201 | constructor(private config: TransportConfig) { 202 | super(); 203 | if (!config.url) { 204 | throw new Error('URL is required for streamable_http transport'); 205 | } 206 | } 207 | 208 | async connect(): Promise { 209 | return new Promise((resolve, reject) => { 210 | try { 211 | this.connected = true; 212 | resolve(new StreamableHttpClientTransport(this.config.url!, this.config.headers)); 213 | } catch (error) { 214 | reject(error); 215 | } 216 | }); 217 | } 218 | 219 | async disconnect(): Promise { 220 | this.connected = false; 221 | } 222 | 223 | getType(): TransportType { 224 | return 'streamable_http'; 225 | } 226 | } 227 | 228 | // Custom Streamable HTTP Transport implementation for GitHub Copilot MCP 229 | class StreamableHttpClientTransport extends EventEmitter implements Transport { 230 | constructor(private url: string, private headers?: Record) { 231 | super(); 232 | } 233 | 234 | async start(): Promise { 235 | // Streamable HTTP transport is connection-less, so we're always "started" 236 | } 237 | 238 | async close(): Promise { 239 | // Nothing to close for streamable HTTP transport 240 | } 241 | 242 | async send(message: any): Promise { 243 | console.log('StreamableHttpTransport: SSE endpoints require persistent connections, not suitable for MCP request-response pattern'); 244 | console.log('StreamableHttpTransport: Message that would be sent:', JSON.stringify(message)); 245 | 246 | // For now, return a mock response to indicate the transport type is not compatible 247 | // with the MCP protocol's request-response pattern 248 | throw new Error('StreamableHttpTransport: SSE endpoints are not compatible with MCP request-response pattern. GitHub Copilot MCP may require a different integration approach.'); 249 | } 250 | } 251 | 252 | export function createTransport(config: TransportConfig): MCPTransport { 253 | switch (config.type) { 254 | case 'stdio': 255 | return new StdioTransport(config); 256 | case 'http': 257 | return new HttpTransport(config); 258 | case 'sse': 259 | return new SSETransport(config); 260 | case 'streamable_http': 261 | return new StreamableHttpTransport(config); 262 | default: 263 | throw new Error(`Unsupported transport type: ${config.type}`); 264 | } 265 | } -------------------------------------------------------------------------------- /src/ui/components/chat-history.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Text } from "ink"; 3 | import { ChatEntry } from "../../agent/grok-agent.js"; 4 | import { DiffRenderer } from "./diff-renderer.js"; 5 | import { MarkdownRenderer } from "../utils/markdown-renderer.js"; 6 | 7 | interface ChatHistoryProps { 8 | entries: ChatEntry[]; 9 | isConfirmationActive?: boolean; 10 | } 11 | 12 | // Memoized ChatEntry component to prevent unnecessary re-renders 13 | const MemoizedChatEntry = React.memo( 14 | ({ entry, index }: { entry: ChatEntry; index: number }) => { 15 | const renderDiff = (diffContent: string, filename?: string) => { 16 | return ( 17 | 22 | ); 23 | }; 24 | 25 | const renderFileContent = (content: string) => { 26 | const lines = content.split("\n"); 27 | 28 | // Calculate minimum indentation like DiffRenderer does 29 | let baseIndentation = Infinity; 30 | for (const line of lines) { 31 | if (line.trim() === "") continue; 32 | const firstCharIndex = line.search(/\S/); 33 | const currentIndent = firstCharIndex === -1 ? 0 : firstCharIndex; 34 | baseIndentation = Math.min(baseIndentation, currentIndent); 35 | } 36 | if (!isFinite(baseIndentation)) { 37 | baseIndentation = 0; 38 | } 39 | 40 | return lines.map((line, index) => { 41 | const displayContent = line.substring(baseIndentation); 42 | return ( 43 | 44 | {displayContent} 45 | 46 | ); 47 | }); 48 | }; 49 | 50 | switch (entry.type) { 51 | case "user": 52 | return ( 53 | 54 | 55 | 56 | {">"} {entry.content} 57 | 58 | 59 | 60 | ); 61 | 62 | case "assistant": 63 | return ( 64 | 65 | 66 | 67 | 68 | {entry.toolCalls ? ( 69 | // If there are tool calls, just show plain text 70 | {entry.content.trim()} 71 | ) : ( 72 | // If no tool calls, render as markdown 73 | 74 | )} 75 | {entry.isStreaming && } 76 | 77 | 78 | 79 | ); 80 | 81 | case "tool_call": 82 | case "tool_result": 83 | const getToolActionName = (toolName: string) => { 84 | // Handle MCP tools with mcp__servername__toolname format 85 | if (toolName.startsWith("mcp__")) { 86 | const parts = toolName.split("__"); 87 | if (parts.length >= 3) { 88 | const serverName = parts[1]; 89 | const actualToolName = parts.slice(2).join("__"); 90 | return `${serverName.charAt(0).toUpperCase() + serverName.slice(1)}(${actualToolName.replace(/_/g, " ")})`; 91 | } 92 | } 93 | 94 | switch (toolName) { 95 | case "view_file": 96 | return "Read"; 97 | case "str_replace_editor": 98 | return "Update"; 99 | case "create_file": 100 | return "Create"; 101 | case "bash": 102 | return "Bash"; 103 | case "search": 104 | return "Search"; 105 | case "create_todo_list": 106 | return "Created Todo"; 107 | case "update_todo_list": 108 | return "Updated Todo"; 109 | default: 110 | return "Tool"; 111 | } 112 | }; 113 | 114 | const toolName = entry.toolCall?.function?.name || "unknown"; 115 | const actionName = getToolActionName(toolName); 116 | 117 | const getFilePath = (toolCall: any) => { 118 | if (toolCall?.function?.arguments) { 119 | try { 120 | const args = JSON.parse(toolCall.function.arguments); 121 | if (toolCall.function.name === "search") { 122 | return args.query; 123 | } 124 | return args.path || args.file_path || args.command || ""; 125 | } catch { 126 | return ""; 127 | } 128 | } 129 | return ""; 130 | }; 131 | 132 | const filePath = getFilePath(entry.toolCall); 133 | const isExecuting = entry.type === "tool_call" || !entry.toolResult; 134 | 135 | // Format JSON content for better readability 136 | const formatToolContent = (content: string, toolName: string) => { 137 | if (toolName.startsWith("mcp__")) { 138 | try { 139 | // Try to parse as JSON and format it 140 | const parsed = JSON.parse(content); 141 | if (Array.isArray(parsed)) { 142 | // For arrays, show a summary instead of full JSON 143 | return `Found ${parsed.length} items`; 144 | } else if (typeof parsed === 'object') { 145 | // For objects, show a formatted version 146 | return JSON.stringify(parsed, null, 2); 147 | } 148 | } catch { 149 | // If not JSON, return as is 150 | return content; 151 | } 152 | } 153 | return content; 154 | }; 155 | const shouldShowDiff = 156 | entry.toolCall?.function?.name === "str_replace_editor" && 157 | entry.toolResult?.success && 158 | entry.content.includes("Updated") && 159 | entry.content.includes("---") && 160 | entry.content.includes("+++"); 161 | 162 | const shouldShowFileContent = 163 | (entry.toolCall?.function?.name === "view_file" || 164 | entry.toolCall?.function?.name === "create_file") && 165 | entry.toolResult?.success && 166 | !shouldShowDiff; 167 | 168 | return ( 169 | 170 | 171 | 172 | 173 | {" "} 174 | {filePath ? `${actionName}(${filePath})` : actionName} 175 | 176 | 177 | 178 | {isExecuting ? ( 179 | ⎿ Executing... 180 | ) : shouldShowFileContent ? ( 181 | 182 | ⎿ File contents: 183 | 184 | {renderFileContent(entry.content)} 185 | 186 | 187 | ) : shouldShowDiff ? ( 188 | // For diff results, show only the summary line, not the raw content 189 | ⎿ {entry.content.split("\n")[0]} 190 | ) : ( 191 | ⎿ {formatToolContent(entry.content, toolName)} 192 | )} 193 | 194 | {shouldShowDiff && !isExecuting && ( 195 | 196 | {renderDiff(entry.content, filePath)} 197 | 198 | )} 199 | 200 | ); 201 | 202 | default: 203 | return null; 204 | } 205 | } 206 | ); 207 | 208 | MemoizedChatEntry.displayName = "MemoizedChatEntry"; 209 | 210 | export function ChatHistory({ 211 | entries, 212 | isConfirmationActive = false, 213 | }: ChatHistoryProps) { 214 | // Filter out tool_call entries with "Executing..." when confirmation is active 215 | const filteredEntries = isConfirmationActive 216 | ? entries.filter( 217 | (entry) => 218 | !(entry.type === "tool_call" && entry.content === "Executing...") 219 | ) 220 | : entries; 221 | 222 | return ( 223 | 224 | {filteredEntries.slice(-20).map((entry, index) => ( 225 | 230 | ))} 231 | 232 | ); 233 | } 234 | -------------------------------------------------------------------------------- /src/ui/components/diff-renderer.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Professional diff renderer component 3 | */ 4 | 5 | import React from 'react'; 6 | import { Box, Text } from 'ink'; 7 | import { Colors } from '../utils/colors.js'; 8 | import crypto from 'crypto'; 9 | import { colorizeCode } from '../utils/code-colorizer.js'; 10 | import { MaxSizedBox } from '../shared/max-sized-box.js'; 11 | 12 | interface DiffLine { 13 | type: 'add' | 'del' | 'context' | 'hunk' | 'other'; 14 | oldLine?: number; 15 | newLine?: number; 16 | content: string; 17 | } 18 | 19 | function parseDiffWithLineNumbers(diffContent: string): DiffLine[] { 20 | const lines = diffContent.split('\n'); 21 | const result: DiffLine[] = []; 22 | let currentOldLine = 0; 23 | let currentNewLine = 0; 24 | let inHunk = false; 25 | const hunkHeaderRegex = /^@@ -(\d+),?\d* \+(\d+),?\d* @@/; 26 | 27 | for (const line of lines) { 28 | const hunkMatch = line.match(hunkHeaderRegex); 29 | if (hunkMatch) { 30 | currentOldLine = parseInt(hunkMatch[1], 10); 31 | currentNewLine = parseInt(hunkMatch[2], 10); 32 | inHunk = true; 33 | result.push({ type: 'hunk', content: line }); 34 | // We need to adjust the starting point because the first line number applies to the *first* actual line change/context, 35 | // but we increment *before* pushing that line. So decrement here. 36 | currentOldLine--; 37 | currentNewLine--; 38 | continue; 39 | } 40 | if (!inHunk) { 41 | // Skip standard Git header lines more robustly 42 | if ( 43 | line.startsWith('--- ') || 44 | line.startsWith('+++ ') || 45 | line.startsWith('diff --git') || 46 | line.startsWith('index ') || 47 | line.startsWith('similarity index') || 48 | line.startsWith('rename from') || 49 | line.startsWith('rename to') || 50 | line.startsWith('new file mode') || 51 | line.startsWith('deleted file mode') 52 | ) 53 | continue; 54 | // If it's not a hunk or header, skip (or handle as 'other' if needed) 55 | continue; 56 | } 57 | if (line.startsWith('+')) { 58 | currentNewLine++; // Increment before pushing 59 | result.push({ 60 | type: 'add', 61 | newLine: currentNewLine, 62 | content: line.substring(1), 63 | }); 64 | } else if (line.startsWith('-')) { 65 | currentOldLine++; // Increment before pushing 66 | result.push({ 67 | type: 'del', 68 | oldLine: currentOldLine, 69 | content: line.substring(1), 70 | }); 71 | } else if (line.startsWith(' ')) { 72 | currentOldLine++; // Increment before pushing 73 | currentNewLine++; 74 | result.push({ 75 | type: 'context', 76 | oldLine: currentOldLine, 77 | newLine: currentNewLine, 78 | content: line.substring(1), 79 | }); 80 | } else if (line.startsWith('\\')) { 81 | // Handle "\ No newline at end of file" 82 | result.push({ type: 'other', content: line }); 83 | } 84 | } 85 | return result; 86 | } 87 | 88 | interface DiffRendererProps { 89 | diffContent: string; 90 | filename?: string; 91 | tabWidth?: number; 92 | availableTerminalHeight?: number; 93 | terminalWidth?: number; 94 | } 95 | 96 | const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization 97 | 98 | export const DiffRenderer = ({ 99 | diffContent, 100 | filename, 101 | tabWidth = DEFAULT_TAB_WIDTH, 102 | availableTerminalHeight, 103 | terminalWidth = 80, 104 | }: DiffRendererProps): React.ReactElement => { 105 | if (!diffContent || typeof diffContent !== 'string') { 106 | return No diff content.; 107 | } 108 | 109 | // Strip the first summary line (e.g. "Updated file.txt with 1 addition and 2 removals") 110 | const lines = diffContent.split('\n'); 111 | const firstLine = lines[0]; 112 | let actualDiffContent = diffContent; 113 | 114 | if (firstLine && (firstLine.startsWith('Updated ') || firstLine.startsWith('Created '))) { 115 | actualDiffContent = lines.slice(1).join('\n'); 116 | } 117 | 118 | const parsedLines = parseDiffWithLineNumbers(actualDiffContent); 119 | 120 | if (parsedLines.length === 0) { 121 | return No changes detected.; 122 | } 123 | 124 | // Always render as diff format to show line numbers and + signs 125 | const renderedOutput = renderDiffContent( 126 | parsedLines, 127 | filename, 128 | tabWidth, 129 | availableTerminalHeight, 130 | terminalWidth, 131 | ); 132 | 133 | return <>{renderedOutput}; 134 | }; 135 | 136 | const renderDiffContent = ( 137 | parsedLines: DiffLine[], 138 | filename: string | undefined, 139 | tabWidth = DEFAULT_TAB_WIDTH, 140 | availableTerminalHeight: number | undefined, 141 | terminalWidth: number, 142 | ) => { 143 | // 1. Normalize whitespace (replace tabs with spaces) *before* further processing 144 | const normalizedLines = parsedLines.map((line) => ({ 145 | ...line, 146 | content: line.content.replace(/\t/g, ' '.repeat(tabWidth)), 147 | })); 148 | 149 | // Filter out non-displayable lines (hunks, potentially 'other') using the normalized list 150 | const displayableLines = normalizedLines.filter( 151 | (l) => l.type !== 'hunk' && l.type !== 'other', 152 | ); 153 | 154 | if (displayableLines.length === 0) { 155 | return No changes detected.; 156 | } 157 | 158 | // Calculate the minimum indentation across all displayable lines 159 | let baseIndentation = Infinity; // Start high to find the minimum 160 | for (const line of displayableLines) { 161 | // Only consider lines with actual content for indentation calculation 162 | if (line.content.trim() === '') continue; 163 | 164 | const firstCharIndex = line.content.search(/\S/); // Find index of first non-whitespace char 165 | const currentIndent = firstCharIndex === -1 ? 0 : firstCharIndex; // Indent is 0 if no non-whitespace found 166 | baseIndentation = Math.min(baseIndentation, currentIndent); 167 | } 168 | // If baseIndentation remained Infinity (e.g., no displayable lines with content), default to 0 169 | if (!isFinite(baseIndentation)) { 170 | baseIndentation = 0; 171 | } 172 | 173 | const key = filename 174 | ? `diff-box-${filename}` 175 | : `diff-box-${crypto.createHash('sha1').update(JSON.stringify(parsedLines)).digest('hex')}`; 176 | 177 | let lastLineNumber: number | null = null; 178 | const MAX_CONTEXT_LINES_WITHOUT_GAP = 5; 179 | 180 | return ( 181 | 186 | {displayableLines.reduce((acc, line, index) => { 187 | // Determine the relevant line number for gap calculation based on type 188 | let relevantLineNumberForGapCalc: number | null = null; 189 | if (line.type === 'add' || line.type === 'context') { 190 | relevantLineNumberForGapCalc = line.newLine ?? null; 191 | } else if (line.type === 'del') { 192 | // For deletions, the gap is typically in relation to the original file's line numbering 193 | relevantLineNumberForGapCalc = line.oldLine ?? null; 194 | } 195 | 196 | if ( 197 | lastLineNumber !== null && 198 | relevantLineNumberForGapCalc !== null && 199 | relevantLineNumberForGapCalc > 200 | lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1 201 | ) { 202 | acc.push( 203 | 204 | {'═'.repeat(terminalWidth)} 205 | , 206 | ); 207 | } 208 | 209 | const lineKey = `diff-line-${index}`; 210 | let gutterNumStr = ''; 211 | let backgroundColor: string | undefined = undefined; 212 | let prefixSymbol = ' '; 213 | let dim = false; 214 | 215 | switch (line.type) { 216 | case 'add': 217 | gutterNumStr = (line.newLine ?? '').toString(); 218 | backgroundColor = '#86efac'; // Light green for additions 219 | prefixSymbol = '+'; 220 | lastLineNumber = line.newLine ?? null; 221 | break; 222 | case 'del': 223 | gutterNumStr = (line.oldLine ?? '').toString(); 224 | backgroundColor = 'redBright'; // Light red for deletions 225 | prefixSymbol = '-'; 226 | // For deletions, update lastLineNumber based on oldLine if it's advancing. 227 | // This helps manage gaps correctly if there are multiple consecutive deletions 228 | // or if a deletion is followed by a context line far away in the original file. 229 | if (line.oldLine !== undefined) { 230 | lastLineNumber = line.oldLine; 231 | } 232 | break; 233 | case 'context': 234 | gutterNumStr = (line.newLine ?? '').toString(); 235 | dim = true; 236 | prefixSymbol = ' '; 237 | lastLineNumber = line.newLine ?? null; 238 | break; 239 | default: 240 | return acc; 241 | } 242 | 243 | const displayContent = line.content.substring(baseIndentation); 244 | 245 | acc.push( 246 | 247 | {gutterNumStr.padEnd(4)} 248 | {prefixSymbol} 249 | 250 | {displayContent} 251 | 252 | , 253 | ); 254 | return acc; 255 | }, [])} 256 | 257 | ); 258 | }; 259 | 260 | 261 | const getLanguageFromExtension = (extension: string): string | null => { 262 | const languageMap: { [key: string]: string } = { 263 | js: 'javascript', 264 | ts: 'typescript', 265 | py: 'python', 266 | json: 'json', 267 | css: 'css', 268 | html: 'html', 269 | sh: 'bash', 270 | md: 'markdown', 271 | yaml: 'yaml', 272 | yml: 'yaml', 273 | txt: 'plaintext', 274 | java: 'java', 275 | c: 'c', 276 | cpp: 'cpp', 277 | rb: 'ruby', 278 | }; 279 | return languageMap[extension] || null; // Return null if extension not found 280 | }; -------------------------------------------------------------------------------- /src/hooks/use-enhanced-input.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useRef } from "react"; 2 | import { 3 | deleteCharBefore, 4 | deleteCharAfter, 5 | deleteWordBefore, 6 | deleteWordAfter, 7 | insertText, 8 | moveToLineStart, 9 | moveToLineEnd, 10 | moveToPreviousWord, 11 | moveToNextWord, 12 | } from "../utils/text-utils.js"; 13 | import { useInputHistory } from "./use-input-history.js"; 14 | 15 | export interface Key { 16 | name?: string; 17 | ctrl?: boolean; 18 | meta?: boolean; 19 | shift?: boolean; 20 | paste?: boolean; 21 | sequence?: string; 22 | upArrow?: boolean; 23 | downArrow?: boolean; 24 | leftArrow?: boolean; 25 | rightArrow?: boolean; 26 | return?: boolean; 27 | escape?: boolean; 28 | tab?: boolean; 29 | backspace?: boolean; 30 | delete?: boolean; 31 | } 32 | 33 | export interface EnhancedInputHook { 34 | input: string; 35 | cursorPosition: number; 36 | isMultiline: boolean; 37 | setInput: (text: string) => void; 38 | setCursorPosition: (position: number) => void; 39 | clearInput: () => void; 40 | insertAtCursor: (text: string) => void; 41 | resetHistory: () => void; 42 | handleInput: (inputChar: string, key: Key) => void; 43 | } 44 | 45 | interface UseEnhancedInputProps { 46 | onSubmit?: (text: string) => void; 47 | onEscape?: () => void; 48 | onSpecialKey?: (key: Key) => boolean; // Return true to prevent default handling 49 | disabled?: boolean; 50 | multiline?: boolean; 51 | } 52 | 53 | export function useEnhancedInput({ 54 | onSubmit, 55 | onEscape, 56 | onSpecialKey, 57 | disabled = false, 58 | multiline = false, 59 | }: UseEnhancedInputProps = {}): EnhancedInputHook { 60 | const [input, setInputState] = useState(""); 61 | const [cursorPosition, setCursorPositionState] = useState(0); 62 | const isMultilineRef = useRef(multiline); 63 | 64 | const { 65 | addToHistory, 66 | navigateHistory, 67 | resetHistory, 68 | setOriginalInput, 69 | isNavigatingHistory, 70 | } = useInputHistory(); 71 | 72 | const setInput = useCallback((text: string) => { 73 | setInputState(text); 74 | setCursorPositionState(Math.min(text.length, cursorPosition)); 75 | if (!isNavigatingHistory()) { 76 | setOriginalInput(text); 77 | } 78 | }, [cursorPosition, isNavigatingHistory, setOriginalInput]); 79 | 80 | const setCursorPosition = useCallback((position: number) => { 81 | setCursorPositionState(Math.max(0, Math.min(input.length, position))); 82 | }, [input.length]); 83 | 84 | const clearInput = useCallback(() => { 85 | setInputState(""); 86 | setCursorPositionState(0); 87 | setOriginalInput(""); 88 | }, [setOriginalInput]); 89 | 90 | const insertAtCursor = useCallback((text: string) => { 91 | const result = insertText(input, cursorPosition, text); 92 | setInputState(result.text); 93 | setCursorPositionState(result.position); 94 | setOriginalInput(result.text); 95 | }, [input, cursorPosition, setOriginalInput]); 96 | 97 | const handleSubmit = useCallback(() => { 98 | if (input.trim()) { 99 | addToHistory(input); 100 | onSubmit?.(input); 101 | clearInput(); 102 | } 103 | }, [input, addToHistory, onSubmit, clearInput]); 104 | 105 | const handleInput = useCallback((inputChar: string, key: Key) => { 106 | if (disabled) return; 107 | 108 | // Handle Ctrl+C - check multiple ways it could be detected 109 | if ((key.ctrl && inputChar === "c") || inputChar === "\x03") { 110 | setInputState(""); 111 | setCursorPositionState(0); 112 | setOriginalInput(""); 113 | return; 114 | } 115 | 116 | // Allow special key handler to override default behavior 117 | if (onSpecialKey?.(key)) { 118 | return; 119 | } 120 | 121 | // Handle Escape 122 | if (key.escape) { 123 | onEscape?.(); 124 | return; 125 | } 126 | 127 | // Handle Enter/Return 128 | if (key.return) { 129 | if (multiline && key.shift) { 130 | // Shift+Enter in multiline mode inserts newline 131 | const result = insertText(input, cursorPosition, "\n"); 132 | setInputState(result.text); 133 | setCursorPositionState(result.position); 134 | setOriginalInput(result.text); 135 | } else { 136 | handleSubmit(); 137 | } 138 | return; 139 | } 140 | 141 | // Handle history navigation 142 | if ((key.upArrow || key.name === 'up') && !key.ctrl && !key.meta) { 143 | const historyInput = navigateHistory("up"); 144 | if (historyInput !== null) { 145 | setInputState(historyInput); 146 | setCursorPositionState(historyInput.length); 147 | } 148 | return; 149 | } 150 | 151 | if ((key.downArrow || key.name === 'down') && !key.ctrl && !key.meta) { 152 | const historyInput = navigateHistory("down"); 153 | if (historyInput !== null) { 154 | setInputState(historyInput); 155 | setCursorPositionState(historyInput.length); 156 | } 157 | return; 158 | } 159 | 160 | // Handle cursor movement - ignore meta flag for arrows as it's unreliable in terminals 161 | // Only do word movement if ctrl is pressed AND no arrow escape sequence is in inputChar 162 | if ((key.leftArrow || key.name === 'left') && key.ctrl && !inputChar.includes('[')) { 163 | const newPos = moveToPreviousWord(input, cursorPosition); 164 | setCursorPositionState(newPos); 165 | return; 166 | } 167 | 168 | if ((key.rightArrow || key.name === 'right') && key.ctrl && !inputChar.includes('[')) { 169 | const newPos = moveToNextWord(input, cursorPosition); 170 | setCursorPositionState(newPos); 171 | return; 172 | } 173 | 174 | // Handle regular cursor movement - single character (ignore meta flag) 175 | if (key.leftArrow || key.name === 'left') { 176 | const newPos = Math.max(0, cursorPosition - 1); 177 | setCursorPositionState(newPos); 178 | return; 179 | } 180 | 181 | if (key.rightArrow || key.name === 'right') { 182 | const newPos = Math.min(input.length, cursorPosition + 1); 183 | setCursorPositionState(newPos); 184 | return; 185 | } 186 | 187 | // Handle Home/End keys or Ctrl+A/E 188 | if ((key.ctrl && inputChar === "a") || key.name === "home") { 189 | setCursorPositionState(0); // Simple start of input 190 | return; 191 | } 192 | 193 | if ((key.ctrl && inputChar === "e") || key.name === "end") { 194 | setCursorPositionState(input.length); // Simple end of input 195 | return; 196 | } 197 | 198 | // Handle deletion - check multiple ways backspace might be detected 199 | // Backspace can be detected in different ways depending on terminal 200 | // In some terminals, backspace shows up as delete:true with empty inputChar 201 | const isBackspace = key.backspace || 202 | key.name === 'backspace' || 203 | inputChar === '\b' || 204 | inputChar === '\x7f' || 205 | (key.delete && inputChar === '' && !key.shift); 206 | 207 | if (isBackspace) { 208 | if (key.ctrl || key.meta) { 209 | // Ctrl/Cmd + Backspace: Delete word before cursor 210 | const result = deleteWordBefore(input, cursorPosition); 211 | setInputState(result.text); 212 | setCursorPositionState(result.position); 213 | setOriginalInput(result.text); 214 | } else { 215 | // Regular backspace 216 | const result = deleteCharBefore(input, cursorPosition); 217 | setInputState(result.text); 218 | setCursorPositionState(result.position); 219 | setOriginalInput(result.text); 220 | } 221 | return; 222 | } 223 | 224 | // Handle forward delete (Del key) - but not if it was already handled as backspace above 225 | if ((key.delete && inputChar !== '') || (key.ctrl && inputChar === "d")) { 226 | if (key.ctrl || key.meta) { 227 | // Ctrl/Cmd + Delete: Delete word after cursor 228 | const result = deleteWordAfter(input, cursorPosition); 229 | setInputState(result.text); 230 | setCursorPositionState(result.position); 231 | setOriginalInput(result.text); 232 | } else { 233 | // Regular delete 234 | const result = deleteCharAfter(input, cursorPosition); 235 | setInputState(result.text); 236 | setCursorPositionState(result.position); 237 | setOriginalInput(result.text); 238 | } 239 | return; 240 | } 241 | 242 | // Handle Ctrl+K: Delete from cursor to end of line 243 | if (key.ctrl && inputChar === "k") { 244 | const lineEnd = moveToLineEnd(input, cursorPosition); 245 | const newText = input.slice(0, cursorPosition) + input.slice(lineEnd); 246 | setInputState(newText); 247 | setOriginalInput(newText); 248 | return; 249 | } 250 | 251 | // Handle Ctrl+U: Delete from cursor to start of line 252 | if (key.ctrl && inputChar === "u") { 253 | const lineStart = moveToLineStart(input, cursorPosition); 254 | const newText = input.slice(0, lineStart) + input.slice(cursorPosition); 255 | setInputState(newText); 256 | setCursorPositionState(lineStart); 257 | setOriginalInput(newText); 258 | return; 259 | } 260 | 261 | // Handle Ctrl+W: Delete word before cursor 262 | if (key.ctrl && inputChar === "w") { 263 | const result = deleteWordBefore(input, cursorPosition); 264 | setInputState(result.text); 265 | setCursorPositionState(result.position); 266 | setOriginalInput(result.text); 267 | return; 268 | } 269 | 270 | // Handle Ctrl+X: Clear entire input 271 | if (key.ctrl && inputChar === "x") { 272 | setInputState(""); 273 | setCursorPositionState(0); 274 | setOriginalInput(""); 275 | return; 276 | } 277 | 278 | // Handle regular character input 279 | if (inputChar && !key.ctrl && !key.meta) { 280 | const result = insertText(input, cursorPosition, inputChar); 281 | setInputState(result.text); 282 | setCursorPositionState(result.position); 283 | setOriginalInput(result.text); 284 | } 285 | }, [disabled, onSpecialKey, input, cursorPosition, multiline, handleSubmit, navigateHistory, setOriginalInput]); 286 | 287 | return { 288 | input, 289 | cursorPosition, 290 | isMultiline: isMultilineRef.current, 291 | setInput, 292 | setCursorPosition, 293 | clearInput, 294 | insertAtCursor, 295 | resetHistory, 296 | handleInput, 297 | }; 298 | } -------------------------------------------------------------------------------- /src/commands/mcp.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'commander'; 2 | import { addMCPServer, removeMCPServer, loadMCPConfig, PREDEFINED_SERVERS } from '../mcp/config.js'; 3 | import { getMCPManager } from '../grok/tools.js'; 4 | import { MCPServerConfig } from '../mcp/client.js'; 5 | import chalk from 'chalk'; 6 | 7 | export function createMCPCommand(): Command { 8 | const mcpCommand = new Command('mcp'); 9 | mcpCommand.description('Manage MCP (Model Context Protocol) servers'); 10 | 11 | // Add server command 12 | mcpCommand 13 | .command('add ') 14 | .description('Add an MCP server') 15 | .option('-t, --transport ', 'Transport type (stdio, http, sse, streamable_http)', 'stdio') 16 | .option('-c, --command ', 'Command to run the server (for stdio transport)') 17 | .option('-a, --args [args...]', 'Arguments for the server command (for stdio transport)', []) 18 | .option('-u, --url ', 'URL for HTTP/SSE transport') 19 | .option('-h, --headers [headers...]', 'HTTP headers (key=value format)', []) 20 | .option('-e, --env [env...]', 'Environment variables (key=value format)', []) 21 | .action(async (name: string, options) => { 22 | try { 23 | // Check if it's a predefined server 24 | if (PREDEFINED_SERVERS[name]) { 25 | const config = PREDEFINED_SERVERS[name]; 26 | addMCPServer(config); 27 | console.log(chalk.green(`✓ Added predefined MCP server: ${name}`)); 28 | 29 | // Try to connect immediately 30 | const manager = getMCPManager(); 31 | await manager.addServer(config); 32 | console.log(chalk.green(`✓ Connected to MCP server: ${name}`)); 33 | 34 | const tools = manager.getTools().filter(t => t.serverName === name); 35 | console.log(chalk.blue(` Available tools: ${tools.length}`)); 36 | 37 | return; 38 | } 39 | 40 | // Custom server 41 | const transportType = options.transport.toLowerCase(); 42 | 43 | if (transportType === 'stdio') { 44 | if (!options.command) { 45 | console.error(chalk.red('Error: --command is required for stdio transport')); 46 | process.exit(1); 47 | } 48 | } else if (transportType === 'http' || transportType === 'sse' || transportType === 'streamable_http') { 49 | if (!options.url) { 50 | console.error(chalk.red(`Error: --url is required for ${transportType} transport`)); 51 | process.exit(1); 52 | } 53 | } else { 54 | console.error(chalk.red('Error: Transport type must be stdio, http, sse, or streamable_http')); 55 | process.exit(1); 56 | } 57 | 58 | // Parse environment variables 59 | const env: Record = {}; 60 | for (const envVar of options.env || []) { 61 | const [key, value] = envVar.split('=', 2); 62 | if (key && value) { 63 | env[key] = value; 64 | } 65 | } 66 | 67 | // Parse headers 68 | const headers: Record = {}; 69 | for (const header of options.headers || []) { 70 | const [key, value] = header.split('=', 2); 71 | if (key && value) { 72 | headers[key] = value; 73 | } 74 | } 75 | 76 | const config = { 77 | name, 78 | transport: { 79 | type: transportType as 'stdio' | 'http' | 'sse' | 'streamable_http', 80 | command: options.command, 81 | args: options.args || [], 82 | url: options.url, 83 | env, 84 | headers: Object.keys(headers).length > 0 ? headers : undefined 85 | } 86 | }; 87 | 88 | addMCPServer(config); 89 | console.log(chalk.green(`✓ Added MCP server: ${name}`)); 90 | 91 | // Try to connect immediately 92 | const manager = getMCPManager(); 93 | await manager.addServer(config); 94 | console.log(chalk.green(`✓ Connected to MCP server: ${name}`)); 95 | 96 | const tools = manager.getTools().filter(t => t.serverName === name); 97 | console.log(chalk.blue(` Available tools: ${tools.length}`)); 98 | 99 | } catch (error: any) { 100 | console.error(chalk.red(`Error adding MCP server: ${error.message}`)); 101 | process.exit(1); 102 | } 103 | }); 104 | 105 | // Add server from JSON command 106 | mcpCommand 107 | .command('add-json ') 108 | .description('Add an MCP server from JSON configuration') 109 | .action(async (name: string, jsonConfig: string) => { 110 | try { 111 | let config; 112 | try { 113 | config = JSON.parse(jsonConfig); 114 | } catch (error) { 115 | console.error(chalk.red('Error: Invalid JSON configuration')); 116 | process.exit(1); 117 | } 118 | 119 | const serverConfig: MCPServerConfig = { 120 | name, 121 | transport: { 122 | type: 'stdio', // default 123 | command: config.command, 124 | args: config.args || [], 125 | env: config.env || {}, 126 | url: config.url, 127 | headers: config.headers 128 | } 129 | }; 130 | 131 | // Override transport type if specified 132 | if (config.transport) { 133 | if (typeof config.transport === 'string') { 134 | serverConfig.transport.type = config.transport as 'stdio' | 'http' | 'sse'; 135 | } else if (typeof config.transport === 'object') { 136 | serverConfig.transport = { ...serverConfig.transport, ...config.transport }; 137 | } 138 | } 139 | 140 | addMCPServer(serverConfig); 141 | console.log(chalk.green(`✓ Added MCP server: ${name}`)); 142 | 143 | // Try to connect immediately 144 | const manager = getMCPManager(); 145 | await manager.addServer(serverConfig); 146 | console.log(chalk.green(`✓ Connected to MCP server: ${name}`)); 147 | 148 | const tools = manager.getTools().filter(t => t.serverName === name); 149 | console.log(chalk.blue(` Available tools: ${tools.length}`)); 150 | 151 | } catch (error: any) { 152 | console.error(chalk.red(`Error adding MCP server: ${error.message}`)); 153 | process.exit(1); 154 | } 155 | }); 156 | 157 | // Remove server command 158 | mcpCommand 159 | .command('remove ') 160 | .description('Remove an MCP server') 161 | .action(async (name: string) => { 162 | try { 163 | const manager = getMCPManager(); 164 | await manager.removeServer(name); 165 | removeMCPServer(name); 166 | console.log(chalk.green(`✓ Removed MCP server: ${name}`)); 167 | } catch (error: any) { 168 | console.error(chalk.red(`Error removing MCP server: ${error.message}`)); 169 | process.exit(1); 170 | } 171 | }); 172 | 173 | // List servers command 174 | mcpCommand 175 | .command('list') 176 | .description('List configured MCP servers') 177 | .action(() => { 178 | const config = loadMCPConfig(); 179 | const manager = getMCPManager(); 180 | 181 | if (config.servers.length === 0) { 182 | console.log(chalk.yellow('No MCP servers configured')); 183 | return; 184 | } 185 | 186 | console.log(chalk.bold('Configured MCP servers:')); 187 | console.log(); 188 | 189 | for (const server of config.servers) { 190 | const isConnected = manager.getServers().includes(server.name); 191 | const status = isConnected 192 | ? chalk.green('✓ Connected') 193 | : chalk.red('✗ Disconnected'); 194 | 195 | console.log(`${chalk.bold(server.name)}: ${status}`); 196 | 197 | // Display transport information 198 | if (server.transport) { 199 | console.log(` Transport: ${server.transport.type}`); 200 | if (server.transport.type === 'stdio') { 201 | console.log(` Command: ${server.transport.command} ${(server.transport.args || []).join(' ')}`); 202 | } else if (server.transport.type === 'http' || server.transport.type === 'sse') { 203 | console.log(` URL: ${server.transport.url}`); 204 | } 205 | } else if (server.command) { 206 | // Legacy format 207 | console.log(` Command: ${server.command} ${(server.args || []).join(' ')}`); 208 | } 209 | 210 | if (isConnected) { 211 | const transportType = manager.getTransportType(server.name); 212 | if (transportType) { 213 | console.log(` Active Transport: ${transportType}`); 214 | } 215 | 216 | const tools = manager.getTools().filter(t => t.serverName === server.name); 217 | console.log(` Tools: ${tools.length}`); 218 | if (tools.length > 0) { 219 | tools.forEach(tool => { 220 | const displayName = tool.name.replace(`mcp__${server.name}__`, ''); 221 | console.log(` - ${displayName}: ${tool.description}`); 222 | }); 223 | } 224 | } 225 | 226 | console.log(); 227 | } 228 | }); 229 | 230 | // Test server command 231 | mcpCommand 232 | .command('test ') 233 | .description('Test connection to an MCP server') 234 | .action(async (name: string) => { 235 | try { 236 | const config = loadMCPConfig(); 237 | const serverConfig = config.servers.find(s => s.name === name); 238 | 239 | if (!serverConfig) { 240 | console.error(chalk.red(`Server ${name} not found`)); 241 | process.exit(1); 242 | } 243 | 244 | console.log(chalk.blue(`Testing connection to ${name}...`)); 245 | 246 | const manager = getMCPManager(); 247 | await manager.addServer(serverConfig); 248 | 249 | const tools = manager.getTools().filter(t => t.serverName === name); 250 | console.log(chalk.green(`✓ Successfully connected to ${name}`)); 251 | console.log(chalk.blue(` Available tools: ${tools.length}`)); 252 | 253 | if (tools.length > 0) { 254 | console.log(' Tools:'); 255 | tools.forEach(tool => { 256 | const displayName = tool.name.replace(`mcp__${name}__`, ''); 257 | console.log(` - ${displayName}: ${tool.description}`); 258 | }); 259 | } 260 | 261 | } catch (error: any) { 262 | console.error(chalk.red(`✗ Failed to connect to ${name}: ${error.message}`)); 263 | process.exit(1); 264 | } 265 | }); 266 | 267 | return mcpCommand; 268 | } -------------------------------------------------------------------------------- /src/utils/settings-manager.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import * as os from "os"; 4 | 5 | /** 6 | * Current settings version - increment this when adding new models or changing settings structure 7 | * This triggers automatic migration for existing users 8 | */ 9 | const SETTINGS_VERSION = 2; 10 | 11 | /** 12 | * User-level settings stored in ~/.grok/user-settings.json 13 | * These are global settings that apply across all projects 14 | */ 15 | export interface UserSettings { 16 | apiKey?: string; // Grok API key 17 | baseURL?: string; // API base URL 18 | defaultModel?: string; // User's preferred default model 19 | models?: string[]; // Available models list 20 | settingsVersion?: number; // Version for migration tracking 21 | } 22 | 23 | /** 24 | * Project-level settings stored in .grok/settings.json 25 | * These are project-specific settings 26 | */ 27 | export interface ProjectSettings { 28 | model?: string; // Current model for this project 29 | mcpServers?: Record; // MCP server configurations 30 | } 31 | 32 | /** 33 | * Default values for user settings 34 | */ 35 | const DEFAULT_USER_SETTINGS: Partial = { 36 | baseURL: "https://api.x.ai/v1", 37 | defaultModel: "grok-code-fast-1", 38 | models: [ 39 | // Grok 4.1 Fast models (2M context, latest - November 2025) 40 | "grok-4-1-fast-reasoning", 41 | "grok-4-1-fast-non-reasoning", 42 | // Grok 4 Fast models (2M context) 43 | "grok-4-fast-reasoning", 44 | "grok-4-fast-non-reasoning", 45 | // Grok 4 flagship (256K context) 46 | "grok-4", 47 | "grok-4-latest", 48 | // Grok Code (optimized for coding, 256K context) 49 | "grok-code-fast-1", 50 | // Grok 3 models (131K context) 51 | "grok-3", 52 | "grok-3-latest", 53 | "grok-3-fast", 54 | "grok-3-mini", 55 | "grok-3-mini-fast", 56 | ], 57 | }; 58 | 59 | /** 60 | * Default values for project settings 61 | */ 62 | const DEFAULT_PROJECT_SETTINGS: Partial = { 63 | model: "grok-code-fast-1", 64 | }; 65 | 66 | /** 67 | * Unified settings manager that handles both user-level and project-level settings 68 | */ 69 | export class SettingsManager { 70 | private static instance: SettingsManager; 71 | 72 | private userSettingsPath: string; 73 | private projectSettingsPath: string; 74 | 75 | private constructor() { 76 | // User settings path: ~/.grok/user-settings.json 77 | this.userSettingsPath = path.join( 78 | os.homedir(), 79 | ".grok", 80 | "user-settings.json" 81 | ); 82 | 83 | // Project settings path: .grok/settings.json (in current working directory) 84 | this.projectSettingsPath = path.join( 85 | process.cwd(), 86 | ".grok", 87 | "settings.json" 88 | ); 89 | } 90 | 91 | /** 92 | * Get singleton instance 93 | */ 94 | public static getInstance(): SettingsManager { 95 | if (!SettingsManager.instance) { 96 | SettingsManager.instance = new SettingsManager(); 97 | } 98 | return SettingsManager.instance; 99 | } 100 | 101 | /** 102 | * Ensure directory exists for a given file path 103 | */ 104 | private ensureDirectoryExists(filePath: string): void { 105 | const dir = path.dirname(filePath); 106 | if (!fs.existsSync(dir)) { 107 | fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); 108 | } 109 | } 110 | 111 | /** 112 | * Load user settings from ~/.grok/user-settings.json 113 | */ 114 | public loadUserSettings(): UserSettings { 115 | try { 116 | if (!fs.existsSync(this.userSettingsPath)) { 117 | // Create default user settings if file doesn't exist 118 | const newSettings = { ...DEFAULT_USER_SETTINGS, settingsVersion: SETTINGS_VERSION }; 119 | this.saveUserSettings(newSettings); 120 | return newSettings; 121 | } 122 | 123 | const content = fs.readFileSync(this.userSettingsPath, "utf-8"); 124 | const settings = JSON.parse(content); 125 | 126 | // Check if migration is needed 127 | const currentVersion = settings.settingsVersion || 1; 128 | if (currentVersion < SETTINGS_VERSION) { 129 | const migratedSettings = this.migrateSettings(settings, currentVersion); 130 | this.saveUserSettings(migratedSettings); 131 | return migratedSettings; 132 | } 133 | 134 | // Merge with defaults to ensure all required fields exist 135 | return { ...DEFAULT_USER_SETTINGS, ...settings }; 136 | } catch (error) { 137 | console.warn( 138 | "Failed to load user settings:", 139 | error instanceof Error ? error.message : "Unknown error" 140 | ); 141 | return { ...DEFAULT_USER_SETTINGS }; 142 | } 143 | } 144 | 145 | /** 146 | * Migrate settings from an older version to the current version 147 | */ 148 | private migrateSettings(settings: UserSettings, fromVersion: number): UserSettings { 149 | let migrated = { ...settings }; 150 | 151 | // Migration from version 1 to 2: Add new Grok 4.1 and Grok 4 Fast models 152 | if (fromVersion < 2) { 153 | const defaultModels = DEFAULT_USER_SETTINGS.models || []; 154 | const existingModels = new Set(migrated.models || []); 155 | 156 | // Add any new models that don't exist in user's current list 157 | const newModels = defaultModels.filter(model => !existingModels.has(model)); 158 | 159 | // Prepend new models to the list (newest models first) 160 | migrated.models = [...newModels, ...(migrated.models || [])]; 161 | } 162 | 163 | // Add future migrations here: 164 | // if (fromVersion < 3) { ... } 165 | 166 | migrated.settingsVersion = SETTINGS_VERSION; 167 | return migrated; 168 | } 169 | 170 | /** 171 | * Save user settings to ~/.grok/user-settings.json 172 | */ 173 | public saveUserSettings(settings: Partial): void { 174 | try { 175 | this.ensureDirectoryExists(this.userSettingsPath); 176 | 177 | // Read existing settings directly to avoid recursion 178 | let existingSettings: UserSettings = { ...DEFAULT_USER_SETTINGS }; 179 | if (fs.existsSync(this.userSettingsPath)) { 180 | try { 181 | const content = fs.readFileSync(this.userSettingsPath, "utf-8"); 182 | const parsed = JSON.parse(content); 183 | existingSettings = { ...DEFAULT_USER_SETTINGS, ...parsed }; 184 | } catch (error) { 185 | // If file is corrupted, use defaults 186 | console.warn("Corrupted user settings file, using defaults"); 187 | } 188 | } 189 | 190 | const mergedSettings = { ...existingSettings, ...settings }; 191 | 192 | fs.writeFileSync( 193 | this.userSettingsPath, 194 | JSON.stringify(mergedSettings, null, 2), 195 | { mode: 0o600 } // Secure permissions for API key 196 | ); 197 | } catch (error) { 198 | console.error( 199 | "Failed to save user settings:", 200 | error instanceof Error ? error.message : "Unknown error" 201 | ); 202 | throw error; 203 | } 204 | } 205 | 206 | /** 207 | * Update a specific user setting 208 | */ 209 | public updateUserSetting( 210 | key: K, 211 | value: UserSettings[K] 212 | ): void { 213 | const settings = { [key]: value } as Partial; 214 | this.saveUserSettings(settings); 215 | } 216 | 217 | /** 218 | * Get a specific user setting 219 | */ 220 | public getUserSetting(key: K): UserSettings[K] { 221 | const settings = this.loadUserSettings(); 222 | return settings[key]; 223 | } 224 | 225 | /** 226 | * Load project settings from .grok/settings.json 227 | */ 228 | public loadProjectSettings(): ProjectSettings { 229 | try { 230 | if (!fs.existsSync(this.projectSettingsPath)) { 231 | // Create default project settings if file doesn't exist 232 | this.saveProjectSettings(DEFAULT_PROJECT_SETTINGS); 233 | return { ...DEFAULT_PROJECT_SETTINGS }; 234 | } 235 | 236 | const content = fs.readFileSync(this.projectSettingsPath, "utf-8"); 237 | const settings = JSON.parse(content); 238 | 239 | // Merge with defaults 240 | return { ...DEFAULT_PROJECT_SETTINGS, ...settings }; 241 | } catch (error) { 242 | console.warn( 243 | "Failed to load project settings:", 244 | error instanceof Error ? error.message : "Unknown error" 245 | ); 246 | return { ...DEFAULT_PROJECT_SETTINGS }; 247 | } 248 | } 249 | 250 | /** 251 | * Save project settings to .grok/settings.json 252 | */ 253 | public saveProjectSettings(settings: Partial): void { 254 | try { 255 | this.ensureDirectoryExists(this.projectSettingsPath); 256 | 257 | // Read existing settings directly to avoid recursion 258 | let existingSettings: ProjectSettings = { ...DEFAULT_PROJECT_SETTINGS }; 259 | if (fs.existsSync(this.projectSettingsPath)) { 260 | try { 261 | const content = fs.readFileSync(this.projectSettingsPath, "utf-8"); 262 | const parsed = JSON.parse(content); 263 | existingSettings = { ...DEFAULT_PROJECT_SETTINGS, ...parsed }; 264 | } catch (error) { 265 | // If file is corrupted, use defaults 266 | console.warn("Corrupted project settings file, using defaults"); 267 | } 268 | } 269 | 270 | const mergedSettings = { ...existingSettings, ...settings }; 271 | 272 | fs.writeFileSync( 273 | this.projectSettingsPath, 274 | JSON.stringify(mergedSettings, null, 2) 275 | ); 276 | } catch (error) { 277 | console.error( 278 | "Failed to save project settings:", 279 | error instanceof Error ? error.message : "Unknown error" 280 | ); 281 | throw error; 282 | } 283 | } 284 | 285 | /** 286 | * Update a specific project setting 287 | */ 288 | public updateProjectSetting( 289 | key: K, 290 | value: ProjectSettings[K] 291 | ): void { 292 | const settings = { [key]: value } as Partial; 293 | this.saveProjectSettings(settings); 294 | } 295 | 296 | /** 297 | * Get a specific project setting 298 | */ 299 | public getProjectSetting( 300 | key: K 301 | ): ProjectSettings[K] { 302 | const settings = this.loadProjectSettings(); 303 | return settings[key]; 304 | } 305 | 306 | /** 307 | * Get the current model with proper fallback logic: 308 | * 1. Project-specific model setting 309 | * 2. User's default model 310 | * 3. System default 311 | */ 312 | public getCurrentModel(): string { 313 | const projectModel = this.getProjectSetting("model"); 314 | if (projectModel) { 315 | return projectModel; 316 | } 317 | 318 | const userDefaultModel = this.getUserSetting("defaultModel"); 319 | if (userDefaultModel) { 320 | return userDefaultModel; 321 | } 322 | 323 | return DEFAULT_PROJECT_SETTINGS.model || "grok-code-fast-1"; 324 | } 325 | 326 | /** 327 | * Set the current model for the project 328 | */ 329 | public setCurrentModel(model: string): void { 330 | this.updateProjectSetting("model", model); 331 | } 332 | 333 | /** 334 | * Get available models list from user settings 335 | */ 336 | public getAvailableModels(): string[] { 337 | const models = this.getUserSetting("models"); 338 | return models || DEFAULT_USER_SETTINGS.models || []; 339 | } 340 | 341 | /** 342 | * Get API key from user settings or environment 343 | */ 344 | public getApiKey(): string | undefined { 345 | // First check environment variable 346 | const envApiKey = process.env.GROK_API_KEY; 347 | if (envApiKey) { 348 | return envApiKey; 349 | } 350 | 351 | // Then check user settings 352 | return this.getUserSetting("apiKey"); 353 | } 354 | 355 | /** 356 | * Get base URL from user settings or environment 357 | */ 358 | public getBaseURL(): string { 359 | // First check environment variable 360 | const envBaseURL = process.env.GROK_BASE_URL; 361 | if (envBaseURL) { 362 | return envBaseURL; 363 | } 364 | 365 | // Then check user settings 366 | const userBaseURL = this.getUserSetting("baseURL"); 367 | return ( 368 | userBaseURL || DEFAULT_USER_SETTINGS.baseURL || "https://api.x.ai/v1" 369 | ); 370 | } 371 | } 372 | 373 | /** 374 | * Convenience function to get the singleton instance 375 | */ 376 | export function getSettingsManager(): SettingsManager { 377 | return SettingsManager.getInstance(); 378 | } 379 | -------------------------------------------------------------------------------- /src/tools/search.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "child_process"; 2 | import { ToolResult } from "../types/index.js"; 3 | import { ConfirmationService } from "../utils/confirmation-service.js"; 4 | import fs from "fs-extra"; 5 | import * as path from "path"; 6 | 7 | export interface SearchResult { 8 | file: string; 9 | line: number; 10 | column: number; 11 | text: string; 12 | match: string; 13 | } 14 | 15 | export interface FileSearchResult { 16 | path: string; 17 | name: string; 18 | score: number; 19 | } 20 | 21 | export interface UnifiedSearchResult { 22 | type: "text" | "file"; 23 | file: string; 24 | line?: number; 25 | column?: number; 26 | text?: string; 27 | match?: string; 28 | score?: number; 29 | } 30 | 31 | export class SearchTool { 32 | private confirmationService = ConfirmationService.getInstance(); 33 | private currentDirectory: string = process.cwd(); 34 | 35 | /** 36 | * Unified search method that can search for text content or find files 37 | */ 38 | async search( 39 | query: string, 40 | options: { 41 | searchType?: "text" | "files" | "both"; 42 | includePattern?: string; 43 | excludePattern?: string; 44 | caseSensitive?: boolean; 45 | wholeWord?: boolean; 46 | regex?: boolean; 47 | maxResults?: number; 48 | fileTypes?: string[]; 49 | excludeFiles?: string[]; 50 | includeHidden?: boolean; 51 | } = {} 52 | ): Promise { 53 | try { 54 | const searchType = options.searchType || "both"; 55 | const results: UnifiedSearchResult[] = []; 56 | 57 | // Search for text content if requested 58 | if (searchType === "text" || searchType === "both") { 59 | const textResults = await this.executeRipgrep(query, options); 60 | results.push( 61 | ...textResults.map((r) => ({ 62 | type: "text" as const, 63 | file: r.file, 64 | line: r.line, 65 | column: r.column, 66 | text: r.text, 67 | match: r.match, 68 | })) 69 | ); 70 | } 71 | 72 | // Search for files if requested 73 | if (searchType === "files" || searchType === "both") { 74 | const fileResults = await this.findFilesByPattern(query, options); 75 | results.push( 76 | ...fileResults.map((r) => ({ 77 | type: "file" as const, 78 | file: r.path, 79 | score: r.score, 80 | })) 81 | ); 82 | } 83 | 84 | if (results.length === 0) { 85 | return { 86 | success: true, 87 | output: `No results found for "${query}"`, 88 | }; 89 | } 90 | 91 | const formattedOutput = this.formatUnifiedResults( 92 | results, 93 | query, 94 | searchType 95 | ); 96 | 97 | return { 98 | success: true, 99 | output: formattedOutput, 100 | }; 101 | } catch (error: any) { 102 | return { 103 | success: false, 104 | error: `Search error: ${error.message}`, 105 | }; 106 | } 107 | } 108 | 109 | /** 110 | * Execute ripgrep command with specified options 111 | */ 112 | private async executeRipgrep( 113 | query: string, 114 | options: { 115 | includePattern?: string; 116 | excludePattern?: string; 117 | caseSensitive?: boolean; 118 | wholeWord?: boolean; 119 | regex?: boolean; 120 | maxResults?: number; 121 | fileTypes?: string[]; 122 | excludeFiles?: string[]; 123 | } 124 | ): Promise { 125 | return new Promise((resolve, reject) => { 126 | const args = [ 127 | "--json", 128 | "--with-filename", 129 | "--line-number", 130 | "--column", 131 | "--no-heading", 132 | "--color=never", 133 | ]; 134 | 135 | // Add case sensitivity 136 | if (!options.caseSensitive) { 137 | args.push("--ignore-case"); 138 | } 139 | 140 | // Add whole word matching 141 | if (options.wholeWord) { 142 | args.push("--word-regexp"); 143 | } 144 | 145 | // Add regex mode 146 | if (!options.regex) { 147 | args.push("--fixed-strings"); 148 | } 149 | 150 | // Add max results limit 151 | if (options.maxResults) { 152 | args.push("--max-count", options.maxResults.toString()); 153 | } 154 | 155 | // Add file type filters 156 | if (options.fileTypes) { 157 | options.fileTypes.forEach((type) => { 158 | args.push("--type", type); 159 | }); 160 | } 161 | 162 | // Add include pattern 163 | if (options.includePattern) { 164 | args.push("--glob", options.includePattern); 165 | } 166 | 167 | // Add exclude pattern 168 | if (options.excludePattern) { 169 | args.push("--glob", `!${options.excludePattern}`); 170 | } 171 | 172 | // Add exclude files 173 | if (options.excludeFiles) { 174 | options.excludeFiles.forEach((file) => { 175 | args.push("--glob", `!${file}`); 176 | }); 177 | } 178 | 179 | // Respect gitignore and common ignore patterns 180 | args.push( 181 | "--no-require-git", 182 | "--follow", 183 | "--glob", 184 | "!.git/**", 185 | "--glob", 186 | "!node_modules/**", 187 | "--glob", 188 | "!.DS_Store", 189 | "--glob", 190 | "!*.log" 191 | ); 192 | 193 | // Add query and search directory 194 | args.push(query, this.currentDirectory); 195 | 196 | const rg = spawn("rg", args); 197 | let output = ""; 198 | let errorOutput = ""; 199 | 200 | rg.stdout.on("data", (data) => { 201 | output += data.toString(); 202 | }); 203 | 204 | rg.stderr.on("data", (data) => { 205 | errorOutput += data.toString(); 206 | }); 207 | 208 | rg.on("close", (code) => { 209 | if (code === 0 || code === 1) { 210 | // 0 = found, 1 = not found 211 | const results = this.parseRipgrepOutput(output); 212 | resolve(results); 213 | } else { 214 | reject(new Error(`Ripgrep failed with code ${code}: ${errorOutput}`)); 215 | } 216 | }); 217 | 218 | rg.on("error", (error) => { 219 | reject(error); 220 | }); 221 | }); 222 | } 223 | 224 | /** 225 | * Parse ripgrep JSON output into SearchResult objects 226 | */ 227 | private parseRipgrepOutput(output: string): SearchResult[] { 228 | const results: SearchResult[] = []; 229 | const lines = output 230 | .trim() 231 | .split("\n") 232 | .filter((line) => line.length > 0); 233 | 234 | for (const line of lines) { 235 | try { 236 | const parsed = JSON.parse(line); 237 | if (parsed.type === "match") { 238 | const data = parsed.data; 239 | results.push({ 240 | file: data.path.text, 241 | line: data.line_number, 242 | column: data.submatches[0]?.start || 0, 243 | text: data.lines.text.trim(), 244 | match: data.submatches[0]?.match?.text || "", 245 | }); 246 | } 247 | } catch (e) { 248 | // Skip invalid JSON lines 249 | continue; 250 | } 251 | } 252 | 253 | return results; 254 | } 255 | 256 | /** 257 | * Find files by pattern using a simple file walking approach 258 | */ 259 | private async findFilesByPattern( 260 | pattern: string, 261 | options: { 262 | maxResults?: number; 263 | includeHidden?: boolean; 264 | excludePattern?: string; 265 | } 266 | ): Promise { 267 | const files: FileSearchResult[] = []; 268 | const maxResults = options.maxResults || 50; 269 | const searchPattern = pattern.toLowerCase(); 270 | 271 | const walkDir = async (dir: string, depth: number = 0): Promise => { 272 | if (depth > 10 || files.length >= maxResults) return; // Prevent infinite recursion and limit results 273 | 274 | try { 275 | const entries = await fs.readdir(dir, { withFileTypes: true }); 276 | 277 | for (const entry of entries) { 278 | if (files.length >= maxResults) break; 279 | 280 | const fullPath = path.join(dir, entry.name); 281 | const relativePath = path.relative(this.currentDirectory, fullPath); 282 | 283 | // Skip hidden files unless explicitly included 284 | if (!options.includeHidden && entry.name.startsWith(".")) { 285 | continue; 286 | } 287 | 288 | // Skip common directories 289 | if ( 290 | entry.isDirectory() && 291 | [ 292 | "node_modules", 293 | ".git", 294 | ".svn", 295 | ".hg", 296 | "dist", 297 | "build", 298 | ".next", 299 | ".cache", 300 | ].includes(entry.name) 301 | ) { 302 | continue; 303 | } 304 | 305 | // Apply exclude pattern 306 | if ( 307 | options.excludePattern && 308 | relativePath.includes(options.excludePattern) 309 | ) { 310 | continue; 311 | } 312 | 313 | if (entry.isFile()) { 314 | const score = this.calculateFileScore( 315 | entry.name, 316 | relativePath, 317 | searchPattern 318 | ); 319 | if (score > 0) { 320 | files.push({ 321 | path: relativePath, 322 | name: entry.name, 323 | score, 324 | }); 325 | } 326 | } else if (entry.isDirectory()) { 327 | await walkDir(fullPath, depth + 1); 328 | } 329 | } 330 | } catch (error) { 331 | // Skip directories we can't read 332 | } 333 | }; 334 | 335 | await walkDir(this.currentDirectory); 336 | 337 | // Sort by score (descending) and return top results 338 | return files.sort((a, b) => b.score - a.score).slice(0, maxResults); 339 | } 340 | 341 | /** 342 | * Calculate fuzzy match score for file names 343 | */ 344 | private calculateFileScore( 345 | fileName: string, 346 | filePath: string, 347 | pattern: string 348 | ): number { 349 | const lowerFileName = fileName.toLowerCase(); 350 | const lowerFilePath = filePath.toLowerCase(); 351 | 352 | // Exact matches get highest score 353 | if (lowerFileName === pattern) return 100; 354 | if (lowerFileName.includes(pattern)) return 80; 355 | 356 | // Path matches get medium score 357 | if (lowerFilePath.includes(pattern)) return 60; 358 | 359 | // Fuzzy matching - check if all characters of pattern exist in order 360 | let patternIndex = 0; 361 | for ( 362 | let i = 0; 363 | i < lowerFileName.length && patternIndex < pattern.length; 364 | i++ 365 | ) { 366 | if (lowerFileName[i] === pattern[patternIndex]) { 367 | patternIndex++; 368 | } 369 | } 370 | 371 | if (patternIndex === pattern.length) { 372 | // All characters found in order - score based on how close they are 373 | return Math.max(10, 40 - (fileName.length - pattern.length)); 374 | } 375 | 376 | return 0; 377 | } 378 | 379 | /** 380 | * Format unified search results for display 381 | */ 382 | private formatUnifiedResults( 383 | results: UnifiedSearchResult[], 384 | query: string, 385 | searchType: string 386 | ): string { 387 | if (results.length === 0) { 388 | return `No results found for "${query}"`; 389 | } 390 | 391 | let output = `Search results for "${query}":\n`; 392 | 393 | // Separate text and file results 394 | const textResults = results.filter((r) => r.type === "text"); 395 | const fileResults = results.filter((r) => r.type === "file"); 396 | 397 | // Show all unique files (from both text matches and file matches) 398 | const allFiles = new Set(); 399 | 400 | // Add files from text results 401 | textResults.forEach((result) => { 402 | allFiles.add(result.file); 403 | }); 404 | 405 | // Add files from file search results 406 | fileResults.forEach((result) => { 407 | allFiles.add(result.file); 408 | }); 409 | 410 | const fileList = Array.from(allFiles); 411 | const displayLimit = 8; 412 | 413 | // Show files in compact format 414 | fileList.slice(0, displayLimit).forEach((file) => { 415 | // Count matches in this file for text results 416 | const matchCount = textResults.filter((r) => r.file === file).length; 417 | const matchIndicator = matchCount > 0 ? ` (${matchCount} matches)` : ""; 418 | output += ` ${file}${matchIndicator}\n`; 419 | }); 420 | 421 | // Show "+X more" if there are additional results 422 | if (fileList.length > displayLimit) { 423 | const remaining = fileList.length - displayLimit; 424 | output += ` ... +${remaining} more\n`; 425 | } 426 | 427 | return output.trim(); 428 | } 429 | 430 | /** 431 | * Update current working directory 432 | */ 433 | setCurrentDirectory(directory: string): void { 434 | this.currentDirectory = directory; 435 | } 436 | 437 | /** 438 | * Get current working directory 439 | */ 440 | getCurrentDirectory(): string { 441 | return this.currentDirectory; 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /src/grok/tools.ts: -------------------------------------------------------------------------------- 1 | import { GrokTool } from "./client.js"; 2 | import { MCPManager, MCPTool } from "../mcp/client.js"; 3 | import { loadMCPConfig } from "../mcp/config.js"; 4 | 5 | const BASE_GROK_TOOLS: GrokTool[] = [ 6 | { 7 | type: "function", 8 | function: { 9 | name: "view_file", 10 | description: "View contents of a file or list directory contents", 11 | parameters: { 12 | type: "object", 13 | properties: { 14 | path: { 15 | type: "string", 16 | description: "Path to file or directory to view", 17 | }, 18 | start_line: { 19 | type: "number", 20 | description: 21 | "Starting line number for partial file view (optional)", 22 | }, 23 | end_line: { 24 | type: "number", 25 | description: "Ending line number for partial file view (optional)", 26 | }, 27 | }, 28 | required: ["path"], 29 | }, 30 | }, 31 | }, 32 | { 33 | type: "function", 34 | function: { 35 | name: "create_file", 36 | description: "Create a new file with specified content", 37 | parameters: { 38 | type: "object", 39 | properties: { 40 | path: { 41 | type: "string", 42 | description: "Path where the file should be created", 43 | }, 44 | content: { 45 | type: "string", 46 | description: "Content to write to the file", 47 | }, 48 | }, 49 | required: ["path", "content"], 50 | }, 51 | }, 52 | }, 53 | { 54 | type: "function", 55 | function: { 56 | name: "str_replace_editor", 57 | description: "Replace specific text in a file. Use this for single line edits only", 58 | parameters: { 59 | type: "object", 60 | properties: { 61 | path: { 62 | type: "string", 63 | description: "Path to the file to edit", 64 | }, 65 | old_str: { 66 | type: "string", 67 | description: 68 | "Text to replace (must match exactly, or will use fuzzy matching for multi-line strings)", 69 | }, 70 | new_str: { 71 | type: "string", 72 | description: "Text to replace with", 73 | }, 74 | replace_all: { 75 | type: "boolean", 76 | description: 77 | "Replace all occurrences (default: false, only replaces first occurrence)", 78 | }, 79 | }, 80 | required: ["path", "old_str", "new_str"], 81 | }, 82 | }, 83 | }, 84 | 85 | { 86 | type: "function", 87 | function: { 88 | name: "bash", 89 | description: "Execute a bash command", 90 | parameters: { 91 | type: "object", 92 | properties: { 93 | command: { 94 | type: "string", 95 | description: "The bash command to execute", 96 | }, 97 | }, 98 | required: ["command"], 99 | }, 100 | }, 101 | }, 102 | { 103 | type: "function", 104 | function: { 105 | name: "search", 106 | description: 107 | "Unified search tool for finding text content or files (similar to Cursor's search)", 108 | parameters: { 109 | type: "object", 110 | properties: { 111 | query: { 112 | type: "string", 113 | description: "Text to search for or file name/path pattern", 114 | }, 115 | search_type: { 116 | type: "string", 117 | enum: ["text", "files", "both"], 118 | description: 119 | "Type of search: 'text' for content search, 'files' for file names, 'both' for both (default: 'both')", 120 | }, 121 | include_pattern: { 122 | type: "string", 123 | description: 124 | "Glob pattern for files to include (e.g. '*.ts', '*.js')", 125 | }, 126 | exclude_pattern: { 127 | type: "string", 128 | description: 129 | "Glob pattern for files to exclude (e.g. '*.log', 'node_modules')", 130 | }, 131 | case_sensitive: { 132 | type: "boolean", 133 | description: 134 | "Whether search should be case sensitive (default: false)", 135 | }, 136 | whole_word: { 137 | type: "boolean", 138 | description: "Whether to match whole words only (default: false)", 139 | }, 140 | regex: { 141 | type: "boolean", 142 | description: "Whether query is a regex pattern (default: false)", 143 | }, 144 | max_results: { 145 | type: "number", 146 | description: "Maximum number of results to return (default: 50)", 147 | }, 148 | file_types: { 149 | type: "array", 150 | items: { type: "string" }, 151 | description: "File types to search (e.g. ['js', 'ts', 'py'])", 152 | }, 153 | include_hidden: { 154 | type: "boolean", 155 | description: "Whether to include hidden files (default: false)", 156 | }, 157 | }, 158 | required: ["query"], 159 | }, 160 | }, 161 | }, 162 | { 163 | type: "function", 164 | function: { 165 | name: "create_todo_list", 166 | description: "Create a new todo list for planning and tracking tasks", 167 | parameters: { 168 | type: "object", 169 | properties: { 170 | todos: { 171 | type: "array", 172 | description: "Array of todo items", 173 | items: { 174 | type: "object", 175 | properties: { 176 | id: { 177 | type: "string", 178 | description: "Unique identifier for the todo item", 179 | }, 180 | content: { 181 | type: "string", 182 | description: "Description of the todo item", 183 | }, 184 | status: { 185 | type: "string", 186 | enum: ["pending", "in_progress", "completed"], 187 | description: "Current status of the todo item", 188 | }, 189 | priority: { 190 | type: "string", 191 | enum: ["high", "medium", "low"], 192 | description: "Priority level of the todo item", 193 | }, 194 | }, 195 | required: ["id", "content", "status", "priority"], 196 | }, 197 | }, 198 | }, 199 | required: ["todos"], 200 | }, 201 | }, 202 | }, 203 | { 204 | type: "function", 205 | function: { 206 | name: "update_todo_list", 207 | description: "Update existing todos in the todo list", 208 | parameters: { 209 | type: "object", 210 | properties: { 211 | updates: { 212 | type: "array", 213 | description: "Array of todo updates", 214 | items: { 215 | type: "object", 216 | properties: { 217 | id: { 218 | type: "string", 219 | description: "ID of the todo item to update", 220 | }, 221 | status: { 222 | type: "string", 223 | enum: ["pending", "in_progress", "completed"], 224 | description: "New status for the todo item", 225 | }, 226 | content: { 227 | type: "string", 228 | description: "New content for the todo item", 229 | }, 230 | priority: { 231 | type: "string", 232 | enum: ["high", "medium", "low"], 233 | description: "New priority for the todo item", 234 | }, 235 | }, 236 | required: ["id"], 237 | }, 238 | }, 239 | }, 240 | required: ["updates"], 241 | }, 242 | }, 243 | }, 244 | ]; 245 | 246 | // Morph Fast Apply tool (conditional) 247 | const MORPH_EDIT_TOOL: GrokTool = { 248 | type: "function", 249 | function: { 250 | name: "edit_file", 251 | description: "Use this tool to make an edit to an existing file.\n\nThis will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write.\nWhen writing the edit, you should specify each edit in sequence, with the special comment // ... existing code ... to represent unchanged code in between edited lines.\n\nFor example:\n\n// ... existing code ...\nFIRST_EDIT\n// ... existing code ...\nSECOND_EDIT\n// ... existing code ...\nTHIRD_EDIT\n// ... existing code ...\n\nYou should still bias towards repeating as few lines of the original file as possible to convey the change.\nBut, each edit should contain sufficient context of unchanged lines around the code you're editing to resolve ambiguity.\nDO NOT omit spans of pre-existing code (or comments) without using the // ... existing code ... comment to indicate its absence. If you omit the existing code comment, the model may inadvertently delete these lines.\nIf you plan on deleting a section, you must provide context before and after to delete it. If the initial code is ```code \\n Block 1 \\n Block 2 \\n Block 3 \\n code```, and you want to remove Block 2, you would output ```// ... existing code ... \\n Block 1 \\n Block 3 \\n // ... existing code ...```.\nMake sure it is clear what the edit should be, and where it should be applied.\nMake edits to a file in a single edit_file call instead of multiple edit_file calls to the same file. The apply model can handle many distinct edits at once.", 252 | parameters: { 253 | type: "object", 254 | properties: { 255 | target_file: { 256 | type: "string", 257 | description: "The target file to modify." 258 | }, 259 | instructions: { 260 | type: "string", 261 | description: "A single sentence instruction describing what you are going to do for the sketched edit. This is used to assist the less intelligent model in applying the edit. Use the first person to describe what you are going to do. Use it to disambiguate uncertainty in the edit." 262 | }, 263 | code_edit: { 264 | type: "string", 265 | description: "Specify ONLY the precise lines of code that you wish to edit. NEVER specify or write out unchanged code. Instead, represent all unchanged code using the comment of the language you're editing in - example: // ... existing code ..." 266 | } 267 | }, 268 | required: ["target_file", "instructions", "code_edit"] 269 | } 270 | } 271 | }; 272 | 273 | // Function to build tools array conditionally 274 | function buildGrokTools(): GrokTool[] { 275 | const tools = [...BASE_GROK_TOOLS]; 276 | 277 | // Add Morph Fast Apply tool if API key is available 278 | if (process.env.MORPH_API_KEY) { 279 | tools.splice(3, 0, MORPH_EDIT_TOOL); // Insert after str_replace_editor 280 | } 281 | 282 | return tools; 283 | } 284 | 285 | // Export dynamic tools array 286 | export const GROK_TOOLS: GrokTool[] = buildGrokTools(); 287 | 288 | // Global MCP manager instance 289 | let mcpManager: MCPManager | null = null; 290 | 291 | export function getMCPManager(): MCPManager { 292 | if (!mcpManager) { 293 | mcpManager = new MCPManager(); 294 | } 295 | return mcpManager; 296 | } 297 | 298 | export async function initializeMCPServers(): Promise { 299 | const manager = getMCPManager(); 300 | const config = loadMCPConfig(); 301 | 302 | // Store original stderr.write 303 | const originalStderrWrite = process.stderr.write; 304 | 305 | // Temporarily suppress stderr to hide verbose MCP connection logs 306 | process.stderr.write = function(chunk: any, encoding?: any, callback?: any): boolean { 307 | // Filter out mcp-remote verbose logs 308 | const chunkStr = chunk.toString(); 309 | if (chunkStr.includes('[') && ( 310 | chunkStr.includes('Using existing client port') || 311 | chunkStr.includes('Connecting to remote server') || 312 | chunkStr.includes('Using transport strategy') || 313 | chunkStr.includes('Connected to remote server') || 314 | chunkStr.includes('Local STDIO server running') || 315 | chunkStr.includes('Proxy established successfully') || 316 | chunkStr.includes('Local→Remote') || 317 | chunkStr.includes('Remote→Local') 318 | )) { 319 | // Suppress these verbose logs 320 | if (callback) callback(); 321 | return true; 322 | } 323 | 324 | // Allow other stderr output 325 | return originalStderrWrite.call(this, chunk, encoding, callback); 326 | }; 327 | 328 | try { 329 | for (const serverConfig of config.servers) { 330 | try { 331 | await manager.addServer(serverConfig); 332 | } catch (error) { 333 | console.warn(`Failed to initialize MCP server ${serverConfig.name}:`, error); 334 | } 335 | } 336 | } finally { 337 | // Restore original stderr.write 338 | process.stderr.write = originalStderrWrite; 339 | } 340 | } 341 | 342 | export function convertMCPToolToGrokTool(mcpTool: MCPTool): GrokTool { 343 | return { 344 | type: "function", 345 | function: { 346 | name: mcpTool.name, 347 | description: mcpTool.description, 348 | parameters: mcpTool.inputSchema || { 349 | type: "object", 350 | properties: {}, 351 | required: [] 352 | } 353 | } 354 | }; 355 | } 356 | 357 | export function addMCPToolsToGrokTools(baseTools: GrokTool[]): GrokTool[] { 358 | if (!mcpManager) { 359 | return baseTools; 360 | } 361 | 362 | const mcpTools = mcpManager.getTools(); 363 | const grokMCPTools = mcpTools.map(convertMCPToolToGrokTool); 364 | 365 | return [...baseTools, ...grokMCPTools]; 366 | } 367 | 368 | export async function getAllGrokTools(): Promise { 369 | const manager = getMCPManager(); 370 | // Try to initialize servers if not already done, but don't block 371 | manager.ensureServersInitialized().catch(() => { 372 | // Ignore initialization errors to avoid blocking 373 | }); 374 | return addMCPToolsToGrokTools(GROK_TOOLS); 375 | } 376 | -------------------------------------------------------------------------------- /src/tools/morph-editor.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import * as path from "path"; 3 | import axios from "axios"; 4 | import { ToolResult } from "../types/index.js"; 5 | import { ConfirmationService } from "../utils/confirmation-service.js"; 6 | 7 | export class MorphEditorTool { 8 | private confirmationService = ConfirmationService.getInstance(); 9 | private morphApiKey: string; 10 | private morphBaseUrl: string = "https://api.morphllm.com/v1"; 11 | 12 | constructor(apiKey?: string) { 13 | this.morphApiKey = apiKey || process.env.MORPH_API_KEY || ""; 14 | if (!this.morphApiKey) { 15 | console.warn("MORPH_API_KEY not found. Morph editor functionality will be limited."); 16 | } 17 | } 18 | 19 | /** 20 | * Use this tool to make an edit to an existing file. 21 | * 22 | * This will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write. 23 | * When writing the edit, you should specify each edit in sequence, with the special comment // ... existing code ... to represent unchanged code in between edited lines. 24 | * 25 | * For example: 26 | * 27 | * // ... existing code ... 28 | * FIRST_EDIT 29 | * // ... existing code ... 30 | * SECOND_EDIT 31 | * // ... existing code ... 32 | * THIRD_EDIT 33 | * // ... existing code ... 34 | * 35 | * You should still bias towards repeating as few lines of the original file as possible to convey the change. 36 | * But, each edit should contain sufficient context of unchanged lines around the code you're editing to resolve ambiguity. 37 | * DO NOT omit spans of pre-existing code (or comments) without using the // ... existing code ... comment to indicate its absence. If you omit the existing code comment, the model may inadvertently delete these lines. 38 | * If you plan on deleting a section, you must provide context before and after to delete it. If the initial code is ```code \n Block 1 \n Block 2 \n Block 3 \n code```, and you want to remove Block 2, you would output ```// ... existing code ... \n Block 1 \n Block 3 \n // ... existing code ...```. 39 | * Make sure it is clear what the edit should be, and where it should be applied. 40 | * Make edits to a file in a single edit_file call instead of multiple edit_file calls to the same file. The apply model can handle many distinct edits at once. 41 | */ 42 | async editFile( 43 | targetFile: string, 44 | instructions: string, 45 | codeEdit: string 46 | ): Promise { 47 | try { 48 | const resolvedPath = path.resolve(targetFile); 49 | 50 | if (!(await fs.pathExists(resolvedPath))) { 51 | return { 52 | success: false, 53 | error: `File not found: ${targetFile}`, 54 | }; 55 | } 56 | 57 | if (!this.morphApiKey) { 58 | return { 59 | success: false, 60 | error: "MORPH_API_KEY not configured. Please set your Morph API key.", 61 | }; 62 | } 63 | 64 | // Read the initial code 65 | const initialCode = await fs.readFile(resolvedPath, "utf-8"); 66 | 67 | // Check user confirmation before proceeding 68 | const sessionFlags = this.confirmationService.getSessionFlags(); 69 | if (!sessionFlags.fileOperations && !sessionFlags.allOperations) { 70 | const confirmationResult = await this.confirmationService.requestConfirmation( 71 | { 72 | operation: "Edit file with Morph Fast Apply", 73 | filename: targetFile, 74 | showVSCodeOpen: false, 75 | content: `Instructions: ${instructions}\n\nEdit:\n${codeEdit}`, 76 | }, 77 | "file" 78 | ); 79 | 80 | if (!confirmationResult.confirmed) { 81 | return { 82 | success: false, 83 | error: confirmationResult.feedback || "File edit cancelled by user", 84 | }; 85 | } 86 | } 87 | 88 | // Call Morph Fast Apply API 89 | const mergedCode = await this.callMorphApply(instructions, initialCode, codeEdit); 90 | 91 | // Write the merged code back to file 92 | await fs.writeFile(resolvedPath, mergedCode, "utf-8"); 93 | 94 | // Generate diff for display 95 | const oldLines = initialCode.split("\n"); 96 | const newLines = mergedCode.split("\n"); 97 | const diff = this.generateDiff(oldLines, newLines, targetFile); 98 | 99 | return { 100 | success: true, 101 | output: diff, 102 | }; 103 | } catch (error: any) { 104 | return { 105 | success: false, 106 | error: `Error editing ${targetFile} with Morph: ${error.message}`, 107 | }; 108 | } 109 | } 110 | 111 | private async callMorphApply( 112 | instructions: string, 113 | initialCode: string, 114 | editSnippet: string 115 | ): Promise { 116 | try { 117 | const response = await axios.post(`${this.morphBaseUrl}/chat/completions`, { 118 | model: "morph-v3-large", 119 | messages: [ 120 | { 121 | role: "user", 122 | content: `${instructions}\n${initialCode}\n${editSnippet}`, 123 | }, 124 | ], 125 | }, { 126 | headers: { 127 | "Authorization": `Bearer ${this.morphApiKey}`, 128 | "Content-Type": "application/json", 129 | }, 130 | }); 131 | 132 | if (!response.data.choices || !response.data.choices[0] || !response.data.choices[0].message) { 133 | throw new Error("Invalid response format from Morph API"); 134 | } 135 | 136 | return response.data.choices[0].message.content; 137 | } catch (error: any) { 138 | if (error.response) { 139 | throw new Error(`Morph API error (${error.response.status}): ${error.response.data}`); 140 | } 141 | throw error; 142 | } 143 | } 144 | 145 | private generateDiff( 146 | oldLines: string[], 147 | newLines: string[], 148 | filePath: string 149 | ): string { 150 | const CONTEXT_LINES = 3; 151 | 152 | const changes: Array<{ 153 | oldStart: number; 154 | oldEnd: number; 155 | newStart: number; 156 | newEnd: number; 157 | }> = []; 158 | 159 | let i = 0, j = 0; 160 | 161 | while (i < oldLines.length || j < newLines.length) { 162 | while (i < oldLines.length && j < newLines.length && oldLines[i] === newLines[j]) { 163 | i++; 164 | j++; 165 | } 166 | 167 | if (i < oldLines.length || j < newLines.length) { 168 | const changeStart = { old: i, new: j }; 169 | 170 | let oldEnd = i; 171 | let newEnd = j; 172 | 173 | while (oldEnd < oldLines.length || newEnd < newLines.length) { 174 | let matchFound = false; 175 | let matchLength = 0; 176 | 177 | for (let k = 0; k < Math.min(2, oldLines.length - oldEnd, newLines.length - newEnd); k++) { 178 | if (oldEnd + k < oldLines.length && 179 | newEnd + k < newLines.length && 180 | oldLines[oldEnd + k] === newLines[newEnd + k]) { 181 | matchLength++; 182 | } else { 183 | break; 184 | } 185 | } 186 | 187 | if (matchLength >= 2 || (oldEnd >= oldLines.length && newEnd >= newLines.length)) { 188 | matchFound = true; 189 | } 190 | 191 | if (matchFound) { 192 | break; 193 | } 194 | 195 | if (oldEnd < oldLines.length) oldEnd++; 196 | if (newEnd < newLines.length) newEnd++; 197 | } 198 | 199 | changes.push({ 200 | oldStart: changeStart.old, 201 | oldEnd: oldEnd, 202 | newStart: changeStart.new, 203 | newEnd: newEnd 204 | }); 205 | 206 | i = oldEnd; 207 | j = newEnd; 208 | } 209 | } 210 | 211 | const hunks: Array<{ 212 | oldStart: number; 213 | oldCount: number; 214 | newStart: number; 215 | newCount: number; 216 | lines: Array<{ type: '+' | '-' | ' '; content: string }>; 217 | }> = []; 218 | 219 | let accumulatedOffset = 0; 220 | 221 | for (let changeIdx = 0; changeIdx < changes.length; changeIdx++) { 222 | const change = changes[changeIdx]; 223 | 224 | let contextStart = Math.max(0, change.oldStart - CONTEXT_LINES); 225 | let contextEnd = Math.min(oldLines.length, change.oldEnd + CONTEXT_LINES); 226 | 227 | if (hunks.length > 0) { 228 | const lastHunk = hunks[hunks.length - 1]; 229 | const lastHunkEnd = lastHunk.oldStart + lastHunk.oldCount; 230 | 231 | if (lastHunkEnd >= contextStart) { 232 | const oldHunkEnd = lastHunk.oldStart + lastHunk.oldCount; 233 | const newContextEnd = Math.min(oldLines.length, change.oldEnd + CONTEXT_LINES); 234 | 235 | for (let idx = oldHunkEnd; idx < change.oldStart; idx++) { 236 | lastHunk.lines.push({ type: ' ', content: oldLines[idx] }); 237 | } 238 | 239 | for (let idx = change.oldStart; idx < change.oldEnd; idx++) { 240 | lastHunk.lines.push({ type: '-', content: oldLines[idx] }); 241 | } 242 | for (let idx = change.newStart; idx < change.newEnd; idx++) { 243 | lastHunk.lines.push({ type: '+', content: newLines[idx] }); 244 | } 245 | 246 | for (let idx = change.oldEnd; idx < newContextEnd && idx < oldLines.length; idx++) { 247 | lastHunk.lines.push({ type: ' ', content: oldLines[idx] }); 248 | } 249 | 250 | lastHunk.oldCount = newContextEnd - lastHunk.oldStart; 251 | lastHunk.newCount = lastHunk.oldCount + (change.newEnd - change.newStart) - (change.oldEnd - change.oldStart); 252 | 253 | continue; 254 | } 255 | } 256 | 257 | const hunk: typeof hunks[0] = { 258 | oldStart: contextStart + 1, 259 | oldCount: contextEnd - contextStart, 260 | newStart: contextStart + 1 + accumulatedOffset, 261 | newCount: contextEnd - contextStart + (change.newEnd - change.newStart) - (change.oldEnd - change.oldStart), 262 | lines: [] 263 | }; 264 | 265 | for (let idx = contextStart; idx < change.oldStart; idx++) { 266 | hunk.lines.push({ type: ' ', content: oldLines[idx] }); 267 | } 268 | 269 | for (let idx = change.oldStart; idx < change.oldEnd; idx++) { 270 | hunk.lines.push({ type: '-', content: oldLines[idx] }); 271 | } 272 | 273 | for (let idx = change.newStart; idx < change.newEnd; idx++) { 274 | hunk.lines.push({ type: '+', content: newLines[idx] }); 275 | } 276 | 277 | for (let idx = change.oldEnd; idx < contextEnd && idx < oldLines.length; idx++) { 278 | hunk.lines.push({ type: ' ', content: oldLines[idx] }); 279 | } 280 | 281 | hunks.push(hunk); 282 | 283 | accumulatedOffset += (change.newEnd - change.newStart) - (change.oldEnd - change.oldStart); 284 | } 285 | 286 | let addedLines = 0; 287 | let removedLines = 0; 288 | 289 | for (const hunk of hunks) { 290 | for (const line of hunk.lines) { 291 | if (line.type === '+') addedLines++; 292 | if (line.type === '-') removedLines++; 293 | } 294 | } 295 | 296 | let summary = `Updated ${filePath} with Morph Fast Apply`; 297 | if (addedLines > 0 && removedLines > 0) { 298 | summary += ` - ${addedLines} addition${ 299 | addedLines !== 1 ? "s" : "" 300 | } and ${removedLines} removal${removedLines !== 1 ? "s" : ""}`; 301 | } else if (addedLines > 0) { 302 | summary += ` - ${addedLines} addition${addedLines !== 1 ? "s" : ""}`; 303 | } else if (removedLines > 0) { 304 | summary += ` - ${removedLines} removal${ 305 | removedLines !== 1 ? "s" : "" 306 | }`; 307 | } else if (changes.length === 0) { 308 | return `No changes applied to ${filePath}`; 309 | } 310 | 311 | let diff = summary + "\n"; 312 | diff += `--- a/${filePath}\n`; 313 | diff += `+++ b/${filePath}\n`; 314 | 315 | for (const hunk of hunks) { 316 | diff += `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@\n`; 317 | 318 | for (const line of hunk.lines) { 319 | diff += `${line.type}${line.content}\n`; 320 | } 321 | } 322 | 323 | return diff.trim(); 324 | } 325 | 326 | async view( 327 | filePath: string, 328 | viewRange?: [number, number] 329 | ): Promise { 330 | try { 331 | const resolvedPath = path.resolve(filePath); 332 | 333 | if (await fs.pathExists(resolvedPath)) { 334 | const stats = await fs.stat(resolvedPath); 335 | 336 | if (stats.isDirectory()) { 337 | const files = await fs.readdir(resolvedPath); 338 | return { 339 | success: true, 340 | output: `Directory contents of ${filePath}:\n${files.join("\n")}`, 341 | }; 342 | } 343 | 344 | const content = await fs.readFile(resolvedPath, "utf-8"); 345 | const lines = content.split("\n"); 346 | 347 | if (viewRange) { 348 | const [start, end] = viewRange; 349 | const selectedLines = lines.slice(start - 1, end); 350 | const numberedLines = selectedLines 351 | .map((line, idx) => `${start + idx}: ${line}`) 352 | .join("\n"); 353 | 354 | return { 355 | success: true, 356 | output: `Lines ${start}-${end} of ${filePath}:\n${numberedLines}`, 357 | }; 358 | } 359 | 360 | const totalLines = lines.length; 361 | const displayLines = totalLines > 10 ? lines.slice(0, 10) : lines; 362 | const numberedLines = displayLines 363 | .map((line, idx) => `${idx + 1}: ${line}`) 364 | .join("\n"); 365 | const additionalLinesMessage = 366 | totalLines > 10 ? `\n... +${totalLines - 10} lines` : ""; 367 | 368 | return { 369 | success: true, 370 | output: `Contents of ${filePath}:\n${numberedLines}${additionalLinesMessage}`, 371 | }; 372 | } else { 373 | return { 374 | success: false, 375 | error: `File or directory not found: ${filePath}`, 376 | }; 377 | } 378 | } catch (error: any) { 379 | return { 380 | success: false, 381 | error: `Error viewing ${filePath}: ${error.message}`, 382 | }; 383 | } 384 | } 385 | 386 | setApiKey(apiKey: string): void { 387 | this.morphApiKey = apiKey; 388 | } 389 | 390 | getApiKey(): string { 391 | return this.morphApiKey; 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /src/ui/components/chat-interface.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import { Box, Text } from "ink"; 3 | import { GrokAgent, ChatEntry } from "../../agent/grok-agent.js"; 4 | import { useInputHandler } from "../../hooks/use-input-handler.js"; 5 | import { LoadingSpinner } from "./loading-spinner.js"; 6 | import { CommandSuggestions } from "./command-suggestions.js"; 7 | import { ModelSelection } from "./model-selection.js"; 8 | import { ChatHistory } from "./chat-history.js"; 9 | import { ChatInput } from "./chat-input.js"; 10 | import { MCPStatus } from "./mcp-status.js"; 11 | import ConfirmationDialog from "./confirmation-dialog.js"; 12 | import { 13 | ConfirmationService, 14 | ConfirmationOptions, 15 | } from "../../utils/confirmation-service.js"; 16 | import ApiKeyInput from "./api-key-input.js"; 17 | import cfonts from "cfonts"; 18 | 19 | interface ChatInterfaceProps { 20 | agent?: GrokAgent; 21 | initialMessage?: string; 22 | } 23 | 24 | // Main chat component that handles input when agent is available 25 | function ChatInterfaceWithAgent({ 26 | agent, 27 | initialMessage, 28 | }: { 29 | agent: GrokAgent; 30 | initialMessage?: string; 31 | }) { 32 | const [chatHistory, setChatHistory] = useState([]); 33 | const [isProcessing, setIsProcessing] = useState(false); 34 | const [processingTime, setProcessingTime] = useState(0); 35 | const [tokenCount, setTokenCount] = useState(0); 36 | const [isStreaming, setIsStreaming] = useState(false); 37 | const [confirmationOptions, setConfirmationOptions] = 38 | useState(null); 39 | const scrollRef = useRef(); 40 | const processingStartTime = useRef(0); 41 | 42 | const confirmationService = ConfirmationService.getInstance(); 43 | 44 | const { 45 | input, 46 | cursorPosition, 47 | showCommandSuggestions, 48 | selectedCommandIndex, 49 | showModelSelection, 50 | selectedModelIndex, 51 | commandSuggestions, 52 | availableModels, 53 | autoEditEnabled, 54 | } = useInputHandler({ 55 | agent, 56 | chatHistory, 57 | setChatHistory, 58 | setIsProcessing, 59 | setIsStreaming, 60 | setTokenCount, 61 | setProcessingTime, 62 | processingStartTime, 63 | isProcessing, 64 | isStreaming, 65 | isConfirmationActive: !!confirmationOptions, 66 | }); 67 | 68 | useEffect(() => { 69 | // Only clear console on non-Windows platforms or if not PowerShell 70 | // Windows PowerShell can have issues with console.clear() causing flickering 71 | const isWindows = process.platform === "win32"; 72 | const isPowerShell = 73 | process.env.ComSpec?.toLowerCase().includes("powershell") || 74 | process.env.PSModulePath !== undefined; 75 | 76 | if (!isWindows || !isPowerShell) { 77 | console.clear(); 78 | } 79 | 80 | // Add top padding 81 | console.log(" "); 82 | 83 | // Generate logo with margin to match Ink paddingX={2} 84 | const logoOutput = cfonts.render("GROK", { 85 | font: "3d", 86 | align: "left", 87 | colors: ["magenta", "gray"], 88 | space: true, 89 | maxLength: "0", 90 | gradient: ["magenta", "cyan"], 91 | independentGradient: false, 92 | transitionGradient: true, 93 | env: "node", 94 | }); 95 | 96 | // Add horizontal margin (2 spaces) to match Ink paddingX={2} 97 | const logoLines = (logoOutput as any).string.split("\n"); 98 | logoLines.forEach((line: string) => { 99 | if (line.trim()) { 100 | console.log(" " + line); // Add 2 spaces for horizontal margin 101 | } else { 102 | console.log(line); // Keep empty lines as-is 103 | } 104 | }); 105 | 106 | console.log(" "); // Spacing after logo 107 | 108 | setChatHistory([]); 109 | }, []); 110 | 111 | // Process initial message if provided (streaming for faster feedback) 112 | useEffect(() => { 113 | if (initialMessage && agent) { 114 | const userEntry: ChatEntry = { 115 | type: "user", 116 | content: initialMessage, 117 | timestamp: new Date(), 118 | }; 119 | setChatHistory([userEntry]); 120 | 121 | const processInitialMessage = async () => { 122 | setIsProcessing(true); 123 | setIsStreaming(true); 124 | 125 | try { 126 | let streamingEntry: ChatEntry | null = null; 127 | for await (const chunk of agent.processUserMessageStream(initialMessage)) { 128 | switch (chunk.type) { 129 | case "content": 130 | if (chunk.content) { 131 | if (!streamingEntry) { 132 | const newStreamingEntry = { 133 | type: "assistant" as const, 134 | content: chunk.content, 135 | timestamp: new Date(), 136 | isStreaming: true, 137 | }; 138 | setChatHistory((prev) => [...prev, newStreamingEntry]); 139 | streamingEntry = newStreamingEntry; 140 | } else { 141 | setChatHistory((prev) => 142 | prev.map((entry, idx) => 143 | idx === prev.length - 1 && entry.isStreaming 144 | ? { ...entry, content: entry.content + chunk.content } 145 | : entry 146 | ) 147 | ); 148 | } 149 | } 150 | break; 151 | case "token_count": 152 | if (chunk.tokenCount !== undefined) { 153 | setTokenCount(chunk.tokenCount); 154 | } 155 | break; 156 | case "tool_calls": 157 | if (chunk.toolCalls) { 158 | // Stop streaming for the current assistant message 159 | setChatHistory((prev) => 160 | prev.map((entry) => 161 | entry.isStreaming 162 | ? { 163 | ...entry, 164 | isStreaming: false, 165 | toolCalls: chunk.toolCalls, 166 | } 167 | : entry 168 | ) 169 | ); 170 | streamingEntry = null; 171 | 172 | // Add individual tool call entries to show tools are being executed 173 | chunk.toolCalls.forEach((toolCall) => { 174 | const toolCallEntry: ChatEntry = { 175 | type: "tool_call", 176 | content: "Executing...", 177 | timestamp: new Date(), 178 | toolCall: toolCall, 179 | }; 180 | setChatHistory((prev) => [...prev, toolCallEntry]); 181 | }); 182 | } 183 | break; 184 | case "tool_result": 185 | if (chunk.toolCall && chunk.toolResult) { 186 | setChatHistory((prev) => 187 | prev.map((entry) => { 188 | if (entry.isStreaming) { 189 | return { ...entry, isStreaming: false }; 190 | } 191 | if ( 192 | entry.type === "tool_call" && 193 | entry.toolCall?.id === chunk.toolCall?.id 194 | ) { 195 | return { 196 | ...entry, 197 | type: "tool_result", 198 | content: chunk.toolResult.success 199 | ? chunk.toolResult.output || "Success" 200 | : chunk.toolResult.error || "Error occurred", 201 | toolResult: chunk.toolResult, 202 | }; 203 | } 204 | return entry; 205 | }) 206 | ); 207 | streamingEntry = null; 208 | } 209 | break; 210 | case "done": 211 | if (streamingEntry) { 212 | setChatHistory((prev) => 213 | prev.map((entry) => 214 | entry.isStreaming ? { ...entry, isStreaming: false } : entry 215 | ) 216 | ); 217 | } 218 | setIsStreaming(false); 219 | break; 220 | } 221 | } 222 | } catch (error: any) { 223 | const errorEntry: ChatEntry = { 224 | type: "assistant", 225 | content: `Error: ${error.message}`, 226 | timestamp: new Date(), 227 | }; 228 | setChatHistory((prev) => [...prev, errorEntry]); 229 | setIsStreaming(false); 230 | } 231 | 232 | setIsProcessing(false); 233 | processingStartTime.current = 0; 234 | }; 235 | 236 | processInitialMessage(); 237 | } 238 | }, [initialMessage, agent]); 239 | 240 | useEffect(() => { 241 | const handleConfirmationRequest = (options: ConfirmationOptions) => { 242 | setConfirmationOptions(options); 243 | }; 244 | 245 | confirmationService.on("confirmation-requested", handleConfirmationRequest); 246 | 247 | return () => { 248 | confirmationService.off( 249 | "confirmation-requested", 250 | handleConfirmationRequest 251 | ); 252 | }; 253 | }, [confirmationService]); 254 | 255 | useEffect(() => { 256 | if (!isProcessing && !isStreaming) { 257 | setProcessingTime(0); 258 | return; 259 | } 260 | 261 | if (processingStartTime.current === 0) { 262 | processingStartTime.current = Date.now(); 263 | } 264 | 265 | const interval = setInterval(() => { 266 | setProcessingTime( 267 | Math.floor((Date.now() - processingStartTime.current) / 1000) 268 | ); 269 | }, 1000); 270 | 271 | return () => clearInterval(interval); 272 | }, [isProcessing, isStreaming]); 273 | 274 | const handleConfirmation = (dontAskAgain?: boolean) => { 275 | confirmationService.confirmOperation(true, dontAskAgain); 276 | setConfirmationOptions(null); 277 | }; 278 | 279 | const handleRejection = (feedback?: string) => { 280 | confirmationService.rejectOperation(feedback); 281 | setConfirmationOptions(null); 282 | 283 | // Reset processing states when operation is cancelled 284 | setIsProcessing(false); 285 | setIsStreaming(false); 286 | setTokenCount(0); 287 | setProcessingTime(0); 288 | processingStartTime.current = 0; 289 | }; 290 | 291 | return ( 292 | 293 | {/* Show tips only when no chat history and no confirmation dialog */} 294 | {chatHistory.length === 0 && !confirmationOptions && ( 295 | 296 | 297 | Tips for getting started: 298 | 299 | 300 | 301 | 1. Ask questions, edit files, or run commands. 302 | 303 | 2. Be specific for the best results. 304 | 305 | 3. Create GROK.md files to customize your interactions with Grok. 306 | 307 | 308 | 4. Press Shift+Tab to toggle auto-edit mode. 309 | 310 | 5. /help for more information. 311 | 312 | 313 | )} 314 | 315 | 316 | 317 | Type your request in natural language. Ctrl+C to clear, 'exit' to 318 | quit. 319 | 320 | 321 | 322 | 323 | 327 | 328 | 329 | {/* Show confirmation dialog if one is pending */} 330 | {confirmationOptions && ( 331 | 339 | )} 340 | 341 | {!confirmationOptions && ( 342 | <> 343 | 348 | 349 | 355 | 356 | 357 | 358 | 359 | {autoEditEnabled ? "▶" : "⏸"} auto-edit:{" "} 360 | {autoEditEnabled ? "on" : "off"} 361 | 362 | 363 | {" "} 364 | (shift + tab) 365 | 366 | 367 | 368 | ≋ {agent.getCurrentModel()} 369 | 370 | 371 | 372 | 373 | 379 | 380 | 386 | 387 | )} 388 | 389 | ); 390 | } 391 | 392 | // Main component that handles API key input or chat interface 393 | export default function ChatInterface({ 394 | agent, 395 | initialMessage, 396 | }: ChatInterfaceProps) { 397 | const [currentAgent, setCurrentAgent] = useState( 398 | agent || null 399 | ); 400 | 401 | const handleApiKeySet = (newAgent: GrokAgent) => { 402 | setCurrentAgent(newAgent); 403 | }; 404 | 405 | if (!currentAgent) { 406 | return ; 407 | } 408 | 409 | return ( 410 | 414 | ); 415 | } 416 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grok CLI 2 | 3 | A conversational AI CLI tool powered by Grok with intelligent text editor capabilities and tool usage. 4 | 5 | Screenshot 2025-07-21 at 13 35 41 6 | 7 | ## Features 8 | 9 | - **🤖 Conversational AI**: Natural language interface powered by Grok-3 10 | - **📝 Smart File Operations**: AI automatically uses tools to view, create, and edit files 11 | - **⚡ Bash Integration**: Execute shell commands through natural conversation 12 | - **🔧 Automatic Tool Selection**: AI intelligently chooses the right tools for your requests 13 | - **🚀 Morph Fast Apply**: Optional high-speed code editing at 4,500+ tokens/sec with 98% accuracy 14 | - **🔌 MCP Tools**: Extend capabilities with Model Context Protocol servers (Linear, GitHub, etc.) 15 | - **💬 Interactive UI**: Beautiful terminal interface built with Ink 16 | - **🌍 Global Installation**: Install and use anywhere with `bun add -g @vibe-kit/grok-cli` 17 | 18 | ## Installation 19 | 20 | ### Prerequisites 21 | - Bun 1.0+ (or Node.js 18+ as fallback) 22 | - Grok API key from X.AI 23 | - (Optional, Recommended) Morph API key for Fast Apply editing 24 | 25 | ### Global Installation (Recommended) 26 | ```bash 27 | bun add -g @vibe-kit/grok-cli 28 | ``` 29 | 30 | Or with npm (fallback): 31 | ```bash 32 | npm install -g @vibe-kit/grok-cli 33 | ``` 34 | 35 | ### Local Development 36 | ```bash 37 | git clone 38 | cd grok-cli 39 | bun install 40 | bun run build 41 | bun link 42 | ``` 43 | 44 | ## Setup 45 | 46 | 1. Get your Grok API key from [X.AI](https://x.ai) 47 | 48 | 2. Set up your API key (choose one method): 49 | 50 | **Method 1: Environment Variable** 51 | ```bash 52 | export GROK_API_KEY=your_api_key_here 53 | ``` 54 | 55 | **Method 2: .env File** 56 | ```bash 57 | cp .env.example .env 58 | # Edit .env and add your API key 59 | ``` 60 | 61 | **Method 3: Command Line Flag** 62 | ```bash 63 | grok --api-key your_api_key_here 64 | ``` 65 | 66 | **Method 4: User Settings File** 67 | Create `~/.grok/user-settings.json`: 68 | ```json 69 | { 70 | "apiKey": "your_api_key_here" 71 | } 72 | ``` 73 | 74 | 3. (Optional, Recommended) Get your Morph API key from [Morph Dashboard](https://morphllm.com/dashboard/api-keys) 75 | 76 | 4. Set up your Morph API key for Fast Apply editing (choose one method): 77 | 78 | **Method 1: Environment Variable** 79 | ```bash 80 | export MORPH_API_KEY=your_morph_api_key_here 81 | ``` 82 | 83 | **Method 2: .env File** 84 | ```bash 85 | # Add to your .env file 86 | MORPH_API_KEY=your_morph_api_key_here 87 | ``` 88 | 89 | ### Custom Base URL (Optional) 90 | 91 | By default, the CLI uses `https://api.x.ai/v1` as the Grok API endpoint. You can configure a custom endpoint if needed (choose one method): 92 | 93 | **Method 1: Environment Variable** 94 | ```bash 95 | export GROK_BASE_URL=https://your-custom-endpoint.com/v1 96 | ``` 97 | 98 | **Method 2: Command Line Flag** 99 | ```bash 100 | grok --api-key your_api_key_here --base-url https://your-custom-endpoint.com/v1 101 | ``` 102 | 103 | **Method 3: User Settings File** 104 | Add to `~/.grok/user-settings.json`: 105 | ```json 106 | { 107 | "apiKey": "your_api_key_here", 108 | "baseURL": "https://your-custom-endpoint.com/v1" 109 | } 110 | ``` 111 | 112 | ## Configuration Files 113 | 114 | Grok CLI uses two types of configuration files to manage settings: 115 | 116 | ### User-Level Settings (`~/.grok/user-settings.json`) 117 | 118 | This file stores **global settings** that apply across all projects. These settings rarely change and include: 119 | 120 | - **API Key**: Your Grok API key 121 | - **Base URL**: Custom API endpoint (if needed) 122 | - **Default Model**: Your preferred model (e.g., `grok-code-fast-1`) 123 | - **Available Models**: List of models you can use 124 | 125 | **Example:** 126 | ```json 127 | { 128 | "apiKey": "your_api_key_here", 129 | "baseURL": "https://api.x.ai/v1", 130 | "defaultModel": "grok-code-fast-1", 131 | "models": [ 132 | "grok-code-fast-1", 133 | "grok-4-latest", 134 | "grok-3-latest", 135 | "grok-3-fast", 136 | "grok-3-mini-fast" 137 | ] 138 | } 139 | ``` 140 | 141 | ### Project-Level Settings (`.grok/settings.json`) 142 | 143 | This file stores **project-specific settings** in your current working directory. It includes: 144 | 145 | - **Current Model**: The model currently in use for this project 146 | - **MCP Servers**: Model Context Protocol server configurations 147 | 148 | **Example:** 149 | ```json 150 | { 151 | "model": "grok-3-fast", 152 | "mcpServers": { 153 | "linear": { 154 | "name": "linear", 155 | "transport": "stdio", 156 | "command": "npx", 157 | "args": ["@linear/mcp-server"] 158 | } 159 | } 160 | } 161 | ``` 162 | 163 | ### How It Works 164 | 165 | 1. **Global Defaults**: User-level settings provide your default preferences 166 | 2. **Project Override**: Project-level settings override defaults for specific projects 167 | 3. **Directory-Specific**: When you change directories, project settings are loaded automatically 168 | 4. **Fallback Logic**: Project model → User default model → System default (`grok-code-fast-1`) 169 | 170 | This means you can have different models for different projects while maintaining consistent global settings like your API key. 171 | 172 | ### Using Other API Providers 173 | 174 | **Important**: Grok CLI uses **OpenAI-compatible APIs**. You can use any provider that implements the OpenAI chat completions standard. 175 | 176 | **Popular Providers**: 177 | - **X.AI (Grok)**: `https://api.x.ai/v1` (default) 178 | - **OpenAI**: `https://api.openai.com/v1` 179 | - **OpenRouter**: `https://openrouter.ai/api/v1` 180 | - **Groq**: `https://api.groq.com/openai/v1` 181 | 182 | **Example with OpenRouter**: 183 | ```json 184 | { 185 | "apiKey": "your_openrouter_key", 186 | "baseURL": "https://openrouter.ai/api/v1", 187 | "defaultModel": "anthropic/claude-3.5-sonnet", 188 | "models": [ 189 | "anthropic/claude-3.5-sonnet", 190 | "openai/gpt-4o", 191 | "meta-llama/llama-3.1-70b-instruct" 192 | ] 193 | } 194 | ``` 195 | 196 | ## Usage 197 | 198 | ### Interactive Mode 199 | 200 | Start the conversational AI assistant: 201 | ```bash 202 | grok 203 | ``` 204 | 205 | Or specify a working directory: 206 | ```bash 207 | grok -d /path/to/project 208 | ``` 209 | 210 | ### Headless Mode 211 | 212 | Process a single prompt and exit (useful for scripting and automation): 213 | ```bash 214 | grok --prompt "show me the package.json file" 215 | grok -p "create a new file called example.js with a hello world function" 216 | grok --prompt "run bun test and show me the results" --directory /path/to/project 217 | grok --prompt "complex task" --max-tool-rounds 50 # Limit tool usage for faster execution 218 | ``` 219 | 220 | This mode is particularly useful for: 221 | - **CI/CD pipelines**: Automate code analysis and file operations 222 | - **Scripting**: Integrate AI assistance into shell scripts 223 | - **Terminal benchmarks**: Perfect for tools like Terminal Bench that need non-interactive execution 224 | - **Batch processing**: Process multiple prompts programmatically 225 | 226 | ### Tool Execution Control 227 | 228 | By default, Grok CLI allows up to 400 tool execution rounds to handle complex multi-step tasks. You can control this behavior: 229 | 230 | ```bash 231 | # Limit tool rounds for faster execution on simple tasks 232 | grok --max-tool-rounds 10 --prompt "show me the current directory" 233 | 234 | # Increase limit for very complex tasks (use with caution) 235 | grok --max-tool-rounds 1000 --prompt "comprehensive code refactoring" 236 | 237 | # Works with all modes 238 | grok --max-tool-rounds 20 # Interactive mode 239 | grok git commit-and-push --max-tool-rounds 30 # Git commands 240 | ``` 241 | 242 | **Use Cases**: 243 | - **Fast responses**: Lower limits (10-50) for simple queries 244 | - **Complex automation**: Higher limits (500+) for comprehensive tasks 245 | - **Resource control**: Prevent runaway executions in automated environments 246 | 247 | ### Model Selection 248 | 249 | You can specify which AI model to use with the `--model` parameter or `GROK_MODEL` environment variable: 250 | 251 | **Method 1: Command Line Flag** 252 | ```bash 253 | # Use Grok models 254 | grok --model grok-code-fast-1 255 | grok --model grok-4-latest 256 | grok --model grok-3-latest 257 | grok --model grok-3-fast 258 | 259 | # Use other models (with appropriate API endpoint) 260 | grok --model gemini-2.5-pro --base-url https://api-endpoint.com/v1 261 | grok --model claude-sonnet-4-20250514 --base-url https://api-endpoint.com/v1 262 | ``` 263 | 264 | **Method 2: Environment Variable** 265 | ```bash 266 | export GROK_MODEL=grok-code-fast-1 267 | grok 268 | ``` 269 | 270 | **Method 3: User Settings File** 271 | Add to `~/.grok/user-settings.json`: 272 | ```json 273 | { 274 | "apiKey": "your_api_key_here", 275 | "defaultModel": "grok-code-fast-1" 276 | } 277 | ``` 278 | 279 | **Model Priority**: `--model` flag > `GROK_MODEL` environment variable > user default model > system default (grok-code-fast-1) 280 | 281 | ### Command Line Options 282 | 283 | ```bash 284 | grok [options] 285 | 286 | Options: 287 | -V, --version output the version number 288 | -d, --directory set working directory 289 | -k, --api-key Grok API key (or set GROK_API_KEY env var) 290 | -u, --base-url Grok API base URL (or set GROK_BASE_URL env var) 291 | -m, --model AI model to use (e.g., grok-code-fast-1, grok-4-latest) (or set GROK_MODEL env var) 292 | -p, --prompt process a single prompt and exit (headless mode) 293 | --max-tool-rounds maximum number of tool execution rounds (default: 400) 294 | -h, --help display help for command 295 | ``` 296 | 297 | ### Custom Instructions 298 | 299 | You can provide custom instructions to tailor Grok's behavior to your project or globally. Grok CLI supports both project-level and global custom instructions. 300 | 301 | #### Project-Level Instructions 302 | 303 | Create a `.grok/GROK.md` file in your project directory to provide instructions specific to that project: 304 | 305 | ```bash 306 | mkdir .grok 307 | ``` 308 | 309 | Create `.grok/GROK.md` with your project-specific instructions: 310 | ```markdown 311 | # Custom Instructions for This Project 312 | 313 | Always use TypeScript for any new code files. 314 | When creating React components, use functional components with hooks. 315 | Prefer const assertions and explicit typing over inference where it improves clarity. 316 | Always add JSDoc comments for public functions and interfaces. 317 | Follow the existing code style and patterns in this project. 318 | ``` 319 | 320 | #### Global Instructions 321 | 322 | For instructions that apply across all projects, create `~/.grok/GROK.md` in your home directory: 323 | 324 | ```bash 325 | mkdir -p ~/.grok 326 | ``` 327 | 328 | Create `~/.grok/GROK.md` with your global instructions: 329 | ```markdown 330 | # Global Custom Instructions for Grok CLI 331 | 332 | Always prioritize code readability and maintainability. 333 | Use descriptive variable names and add comments for complex logic. 334 | Follow best practices for the programming language being used. 335 | When suggesting code changes, consider performance implications. 336 | ``` 337 | 338 | #### Priority Order 339 | 340 | Grok will load custom instructions in the following priority order: 341 | 1. **Project-level** (`.grok/GROK.md` in current directory) - takes highest priority 342 | 2. **Global** (`~/.grok/GROK.md` in home directory) - fallback if no project instructions exist 343 | 344 | If both files exist, project instructions will be used. If neither exists, Grok operates with its default behavior. 345 | 346 | The custom instructions are added to Grok's system prompt and influence its responses across all interactions in the respective context. 347 | 348 | ## Morph Fast Apply (Optional) 349 | 350 | Grok CLI supports Morph's Fast Apply model for high-speed code editing at **4,500+ tokens/sec with 98% accuracy**. This is an optional feature that provides lightning-fast file editing capabilities. 351 | 352 | **Setup**: Configure your Morph API key following the [setup instructions](#setup) above. 353 | 354 | ### How It Works 355 | 356 | When `MORPH_API_KEY` is configured: 357 | - **`edit_file` tool becomes available** alongside the standard `str_replace_editor` 358 | - **Optimized for complex edits**: Use for multi-line changes, refactoring, and large modifications 359 | - **Intelligent editing**: Uses abbreviated edit format with `// ... existing code ...` comments 360 | - **Fallback support**: Standard tools remain available if Morph is unavailable 361 | 362 | **When to use each tool:** 363 | - **`edit_file`** (Morph): Complex edits, refactoring, multi-line changes 364 | - **`str_replace_editor`**: Simple text replacements, single-line edits 365 | 366 | ### Example Usage 367 | 368 | With Morph Fast Apply configured, you can request complex code changes: 369 | 370 | ```bash 371 | grok --prompt "refactor this function to use async/await and add error handling" 372 | grok -p "convert this class to TypeScript and add proper type annotations" 373 | ``` 374 | 375 | The AI will automatically choose between `edit_file` (Morph) for complex changes or `str_replace_editor` for simple replacements. 376 | 377 | ## MCP Tools 378 | 379 | Grok CLI supports MCP (Model Context Protocol) servers, allowing you to extend the AI assistant with additional tools and capabilities. 380 | 381 | ### Adding MCP Tools 382 | 383 | #### Add a custom MCP server: 384 | ```bash 385 | # Add an stdio-based MCP server 386 | grok mcp add my-server --transport stdio --command "bun" --args server.js 387 | 388 | # Add an HTTP-based MCP server 389 | grok mcp add my-server --transport http --url "http://localhost:3000" 390 | 391 | # Add with environment variables 392 | grok mcp add my-server --transport stdio --command "python" --args "-m" "my_mcp_server" --env "API_KEY=your_key" 393 | ``` 394 | 395 | #### Add from JSON configuration: 396 | ```bash 397 | grok mcp add-json my-server '{"command": "bun", "args": ["server.js"], "env": {"API_KEY": "your_key"}}' 398 | ``` 399 | 400 | ### Linear Integration Example 401 | 402 | To add Linear MCP tools for project management: 403 | 404 | ```bash 405 | # Add Linear MCP server 406 | grok mcp add linear --transport sse --url "https://mcp.linear.app/sse" 407 | ``` 408 | 409 | This enables Linear tools like: 410 | - Create and manage Linear issues 411 | - Search and filter issues 412 | - Update issue status and assignees 413 | - Access team and project information 414 | 415 | ### Managing MCP Servers 416 | 417 | ```bash 418 | # List all configured servers 419 | grok mcp list 420 | 421 | # Test server connection 422 | grok mcp test server-name 423 | 424 | # Remove a server 425 | grok mcp remove server-name 426 | ``` 427 | 428 | ### Available Transport Types 429 | 430 | - **stdio**: Run MCP server as a subprocess (most common) 431 | - **http**: Connect to HTTP-based MCP server 432 | - **sse**: Connect via Server-Sent Events 433 | 434 | ## Development 435 | 436 | ```bash 437 | # Install dependencies 438 | bun install 439 | 440 | # Development mode 441 | bun run dev 442 | 443 | # Build project 444 | bun run build 445 | 446 | # Run linter 447 | bun run lint 448 | 449 | # Type check 450 | bun run typecheck 451 | ``` 452 | 453 | ## Architecture 454 | 455 | - **Agent**: Core command processing and execution logic 456 | - **Tools**: Text editor and bash tool implementations 457 | - **UI**: Ink-based terminal interface components 458 | - **Types**: TypeScript definitions for the entire system 459 | 460 | ## License 461 | 462 | MIT 463 | --------------------------------------------------------------------------------