New dev, same as old dev
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
.PHONY: help setup dev build test clean install-local login
|
||||
|
||||
# Colors
|
||||
GREEN := $(shell tput -Txterm setaf 2)
|
||||
YELLOW := $(shell tput -Txterm setaf 3)
|
||||
WHITE := $(shell tput -Txterm setaf 7)
|
||||
CYAN := $(shell tput -Txterm setaf 6)
|
||||
RESET := $(shell tput -Txterm sgr0)
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo ""
|
||||
@echo "${GREEN}Hanzo Dev - Local Development${RESET}"
|
||||
@echo ""
|
||||
@echo "${YELLOW}Available commands:${RESET}"
|
||||
@echo " ${CYAN}make setup${RESET} - Set up for local development"
|
||||
@echo " ${CYAN}make dev${RESET} - Start development mode"
|
||||
@echo " ${CYAN}make build${RESET} - Build all packages"
|
||||
@echo " ${CYAN}make install-local${RESET} - Install hanzo-dev locally"
|
||||
@echo " ${CYAN}make login${RESET} - Login to Hanzo AI"
|
||||
@echo " ${CYAN}make test${RESET} - Run tests"
|
||||
@echo " ${CYAN}make clean${RESET} - Clean build artifacts"
|
||||
@echo ""
|
||||
|
||||
# Setup for local development
|
||||
setup:
|
||||
@echo "${GREEN}Setting up Hanzo Dev...${RESET}"
|
||||
@npm install
|
||||
@make build
|
||||
@make install-local
|
||||
@echo "${GREEN}✓ Setup complete!${RESET}"
|
||||
@echo ""
|
||||
@echo "${YELLOW}Next steps:${RESET}"
|
||||
@echo "1. Run: ${CYAN}make login${RESET}"
|
||||
@echo "2. Run: ${CYAN}hanzo-dev init${RESET}"
|
||||
@echo ""
|
||||
|
||||
# Development mode
|
||||
dev:
|
||||
@echo "${GREEN}Starting development mode...${RESET}"
|
||||
@npm run watch &
|
||||
@cd packages/dev && npm run dev &
|
||||
@cd packages/mcp && npm run dev &
|
||||
@echo "${GREEN}Development servers started!${RESET}"
|
||||
@echo "Press Ctrl+C to stop"
|
||||
@wait
|
||||
|
||||
# Build all packages
|
||||
build:
|
||||
@echo "${GREEN}Building VS Code extension...${RESET}"
|
||||
@npm run compile
|
||||
@echo "${GREEN}Building CLI (@hanzo/dev)...${RESET}"
|
||||
@cd packages/dev && npm install && npm run build
|
||||
@echo "${GREEN}Building MCP (@hanzo/mcp)...${RESET}"
|
||||
@cd packages/mcp && npm install && npm run build
|
||||
@echo "${GREEN}✓ Build complete!${RESET}"
|
||||
|
||||
# Install hanzo-dev command locally
|
||||
install-local:
|
||||
@echo "${GREEN}Installing hanzo-dev locally...${RESET}"
|
||||
@cd packages/dev && npm link
|
||||
@echo "${GREEN}✓ hanzo-dev installed!${RESET}"
|
||||
@echo "Run '${CYAN}hanzo-dev --help${RESET}' to get started"
|
||||
|
||||
# Login to Hanzo AI
|
||||
login:
|
||||
@echo "${GREEN}Logging in to Hanzo AI...${RESET}"
|
||||
@hanzo-dev login
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
@echo "${GREEN}Running tests...${RESET}"
|
||||
@npm test
|
||||
@cd packages/dev && npm test
|
||||
@cd packages/mcp && npm test
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@echo "${YELLOW}Cleaning build artifacts...${RESET}"
|
||||
@rm -rf out dist
|
||||
@rm -rf packages/dev/dist
|
||||
@rm -rf packages/mcp/dist
|
||||
@rm -rf node_modules
|
||||
@rm -rf packages/dev/node_modules
|
||||
@rm -rf packages/mcp/node_modules
|
||||
@echo "${GREEN}✓ Clean complete!${RESET}"
|
||||
|
||||
# Quick commands for development
|
||||
run-claude:
|
||||
@hanzo-dev run claude "$(TASK)"
|
||||
|
||||
run-aider:
|
||||
@hanzo-dev run aider "$(TASK)" --auto-commit
|
||||
|
||||
run-openhands:
|
||||
@hanzo-dev run openhands "$(TASK)" --worktree
|
||||
|
||||
compare:
|
||||
@hanzo-dev compare "$(TASK)"
|
||||
|
||||
# Example usage:
|
||||
# make run-claude TASK="implement a REST API"
|
||||
# make run-aider TASK="fix the failing tests"
|
||||
# make compare TASK="optimize this database query"
|
||||
@@ -1,9 +1,9 @@
|
||||
# Hanzo AI
|
||||
# Dev - Meta AI Development Platform 🚀
|
||||
|
||||
[](https://github.com/hanzoai/extension/actions/workflows/vscode-extension.yml)
|
||||
[](https://github.com/hanzoai/extension/actions/workflows/jetbrains-plugin.yml)
|
||||
[](https://github.com/hanzoai/dev/actions/workflows/vscode-extension.yml)
|
||||
[](https://github.com/hanzoai/dev/actions/workflows/jetbrains-plugin.yml)
|
||||
|
||||
The ultimate toolkit for AI engineers.
|
||||
The ultimate meta AI development platform. Manage and run ALL AI tools (Claude, Codex, Gemini, OpenHands, Aider) in one unified interface with authentication, API management, and parallel execution.
|
||||
|
||||
## What You Get
|
||||
|
||||
@@ -16,76 +16,144 @@ The ultimate toolkit for AI engineers.
|
||||
- **Browser Automation** - Built-in Playwright for web tasks
|
||||
- **Team Collaboration** - Shared context and credits
|
||||
|
||||
## Quick Start
|
||||
## 🚀 Quick Start - Get Running in 2 Minutes
|
||||
|
||||
```bash
|
||||
# VS Code / Cursor / Windsurf
|
||||
Install hanzoai-*.vsix
|
||||
# Clone and setup
|
||||
git clone https://github.com/hanzoai/dev.git
|
||||
cd dev
|
||||
make setup
|
||||
|
||||
# Claude Code
|
||||
Drag hanzoai-*.dxt
|
||||
# Login to Hanzo AI (opens browser)
|
||||
make login
|
||||
# OR use existing API key
|
||||
export HANZO_API_KEY=hzo_... # from iam.hanzo.ai
|
||||
|
||||
# JetBrains IDEs (IntelliJ, PyCharm, WebStorm, etc.)
|
||||
Install hanzo-ai-plugin.zip via Settings → Plugins → Install from Disk
|
||||
# You're ready! Run any AI tool:
|
||||
dev run claude "implement a REST API"
|
||||
dev run aider "fix the failing tests" --auto-commit
|
||||
dev run openhands "analyze this codebase" --worktree
|
||||
```
|
||||
|
||||
# Terminal / Neovim
|
||||
## 📦 Installation Options
|
||||
|
||||
### Option 1: CLI Tool (@hanzo/dev) - Recommended for Quick Start
|
||||
```bash
|
||||
# Install globally
|
||||
npm install -g @hanzo/dev
|
||||
|
||||
# Login with your Hanzo account
|
||||
dev login
|
||||
|
||||
# Initialize in your project
|
||||
dev init
|
||||
```
|
||||
|
||||
### Option 2: VS Code Extension (Hanzo AI)
|
||||
```bash
|
||||
# Install from marketplace
|
||||
code --install-extension hanzo-ai.hanzo-ai
|
||||
|
||||
# Or install .vsix locally
|
||||
code --install-extension hanzo-ai-*.vsix
|
||||
```
|
||||
|
||||
### Option 3: MCP Server (@hanzo/mcp)
|
||||
```bash
|
||||
# For Claude Desktop
|
||||
npx @hanzo/mcp@latest
|
||||
```
|
||||
|
||||
## Use It
|
||||
## 🎯 Core Features
|
||||
|
||||
### 🔐 Unified Authentication & API Management
|
||||
```bash
|
||||
# Login to Hanzo AI
|
||||
@hanzo login # Opens iam.hanzo.ai in browser
|
||||
# Login once, use everywhere
|
||||
dev login
|
||||
|
||||
# Or set API key directly
|
||||
export HANZO_API_KEY=hzo_... # from iam.hanzo.ai
|
||||
|
||||
# Talk to any model
|
||||
@hanzo agent --model o3-pro solve this algorithm
|
||||
@hanzo agent --model claude-4 review my code
|
||||
|
||||
# Activate legendary modes
|
||||
@hanzo mode carmack # Optimize like a game engine
|
||||
@hanzo mode norvig # AI implementation mastery
|
||||
|
||||
# Control browsers
|
||||
@hanzo browser navigate https://example.com
|
||||
@hanzo browser screenshot
|
||||
|
||||
# Search everything
|
||||
@hanzo search "auth flow"
|
||||
|
||||
# Symbol search across projects
|
||||
@hanzo symbols "class UserController"
|
||||
@hanzo symbols "function authenticate"
|
||||
|
||||
# Install any MCP server
|
||||
@hanzo mcp --action install --package @modelcontextprotocol/server-github
|
||||
@hanzo mcp --action call --tool github_search --args '{"query": "MCP"}'
|
||||
# All your API keys are synced and encrypted locally
|
||||
# - OpenAI/Codex API keys
|
||||
# - Anthropic/Claude API keys
|
||||
# - Google/Gemini API keys
|
||||
# - Auto-passthrough to all tools
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### VS Code Extension
|
||||
### 🤖 Run Any AI Tool
|
||||
```bash
|
||||
# Claude - Advanced reasoning and coding
|
||||
dev run claude "refactor this authentication system"
|
||||
|
||||
# Aider - Git-aware pair programming
|
||||
dev run aider "add test coverage" --auto-commit
|
||||
|
||||
# OpenHands - Autonomous software engineering
|
||||
dev run openhands "implement user management" --worktree
|
||||
|
||||
# Codex - Code generation
|
||||
dev run codex "generate a REST API client"
|
||||
|
||||
# Gemini - Multimodal AI
|
||||
dev run gemini "analyze this architecture diagram"
|
||||
```
|
||||
|
||||
### ⚡ Async Long-Running Tasks
|
||||
```bash
|
||||
# Start task in background (auto-quits after 5min idle)
|
||||
dev run claude "migrate database to PostgreSQL" --async
|
||||
# Output: Job ID: abc123...
|
||||
|
||||
# Check status
|
||||
dev status abc123
|
||||
|
||||
# Keep alive and send more instructions
|
||||
dev input abc123 "also add connection pooling"
|
||||
```
|
||||
|
||||
### 🌳 Parallel Development with Git Worktrees
|
||||
```bash
|
||||
# Spawn multiple AI agents working in parallel
|
||||
dev run claude "implement auth" --worktree
|
||||
dev run aider "add tests" --worktree
|
||||
dev run openhands "write docs" --worktree
|
||||
|
||||
# Each runs in its own branch and directory
|
||||
dev worktree list
|
||||
```
|
||||
|
||||
### 🔄 Compare AI Tools
|
||||
```bash
|
||||
# See how different AIs approach the same problem
|
||||
dev compare "optimize this database query"
|
||||
|
||||
# Output shows results from all tools side-by-side
|
||||
```
|
||||
|
||||
## 🛠️ Local Development
|
||||
|
||||
```bash
|
||||
# Quick setup with Make
|
||||
make setup # Install everything
|
||||
make dev # Start dev mode
|
||||
make test # Run tests
|
||||
|
||||
# Manual setup
|
||||
npm install
|
||||
npm run compile
|
||||
npm test
|
||||
vsce package # Build VSIX
|
||||
cd packages/dev && npm install && npm link
|
||||
cd packages/mcp && npm install
|
||||
|
||||
# Run locally
|
||||
dev --help
|
||||
```
|
||||
|
||||
### JetBrains Plugin
|
||||
### Build from Source
|
||||
```bash
|
||||
cd jetbrains-plugin
|
||||
./gradlew build
|
||||
# Or with Docker:
|
||||
./build-plugin-simple.sh
|
||||
```
|
||||
# Build everything
|
||||
make build
|
||||
|
||||
### Claude Code Extension
|
||||
```bash
|
||||
npm run build:dxt
|
||||
# Or individually:
|
||||
npm run compile # VS Code extension
|
||||
cd packages/dev && npm run build # CLI tool
|
||||
cd packages/mcp && npm run build # MCP server
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
@@ -95,10 +163,18 @@ All extensions are automatically built and tested on push:
|
||||
- JetBrains plugin tests run on Java 17
|
||||
- Releases are created automatically for tagged versions
|
||||
|
||||
## Links
|
||||
## 🏗️ Architecture
|
||||
|
||||
- **Dev CLI** (`@hanzo/dev`) - Command-line interface for all AI tools
|
||||
- **Hanzo AI Extension** - VS Code/JetBrains integration
|
||||
- **MCP Server** (`@hanzo/mcp`) - Model Context Protocol tools
|
||||
- **Platform Sync** - Universal context and bi-directional file sync
|
||||
- **Async Wrapper** - Long-running task management with idle detection
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
🚀 **[Login to Hanzo AI](https://iam.hanzo.ai)** | 🌐 **[Hanzo AI](https://hanzo.ai)** | 📖 **[Docs](https://docs.hanzo.ai)** | 💬 **[Discord](https://discord.gg/hanzoai)**
|
||||
|
||||
---
|
||||
|
||||
Built for engineers who ship.
|
||||
Built with ❤️ for engineers who ship fast.
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "hanzoai",
|
||||
"displayName": "Hanzo AI Context Manager",
|
||||
"description": "The ultimate toolkit for AI engineers. Access 200+ LLMs (OpenRouter/LiteLLM compatible), 4000+ MCP servers, and powerful development tools. Features unified API, shared context, intelligent routing, and VS Code native integration.",
|
||||
"name": "hanzo-ai",
|
||||
"displayName": "Hanzo AI",
|
||||
"description": "The ultimate meta AI development platform. Manage and run all LLMs and CLI tools (Claude, Codex, Gemini, OpenHands, Aider) in one unified interface. Features MCP integration, async execution, git worktrees, and universal context sync.",
|
||||
"version": "1.5.4",
|
||||
"publisher": "hanzo-ai",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/extension.git"
|
||||
"url": "https://github.com/hanzoai/dev.git"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@hanzo/dev",
|
||||
"version": "1.0.0",
|
||||
"description": "Hanzo Dev - Meta AI development CLI that manages and runs all LLMs and CLI tools",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"dev": "./dist/cli/dev.js",
|
||||
"hanzo-dev": "./dist/cli/dev.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "jest",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"llm",
|
||||
"cli",
|
||||
"claude",
|
||||
"openai",
|
||||
"gemini",
|
||||
"aider",
|
||||
"openhands",
|
||||
"development",
|
||||
"tools"
|
||||
],
|
||||
"author": "Hanzo AI",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^11.1.0",
|
||||
"inquirer": "^9.2.12",
|
||||
"ora": "^7.0.1",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/inquirer": "^9.0.7",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/ws": "^8.5.10",
|
||||
"jest": "^29.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/dev.git",
|
||||
"directory": "packages/dev"
|
||||
},
|
||||
"homepage": "https://hanzo.ai",
|
||||
"bugs": {
|
||||
"url": "https://github.com/hanzoai/dev/issues"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "../../src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"removeComments": false,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"../../src/cli/**/*",
|
||||
"../../src/cli-tools/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@hanzo/mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "Hanzo MCP Server - Model Context Protocol tools for AI development",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "jest",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"model-context-protocol",
|
||||
"ai",
|
||||
"claude",
|
||||
"tools",
|
||||
"development"
|
||||
],
|
||||
"author": "Hanzo AI",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^0.6.2",
|
||||
"glob": "^10.3.10",
|
||||
"minimatch": "^9.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/glob": "^8.1.0",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/node": "^20.10.5",
|
||||
"jest": "^29.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/dev.git",
|
||||
"directory": "packages/mcp"
|
||||
},
|
||||
"homepage": "https://hanzo.ai",
|
||||
"bugs": {
|
||||
"url": "https://github.com/hanzoai/dev/issues"
|
||||
}
|
||||
}
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Hanzo Dev Local Setup Script
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Setting up Hanzo Dev for local development..."
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[0;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Check Node.js
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo -e "${RED}❌ Node.js is required but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Node.js found: $(node --version)${NC}"
|
||||
|
||||
# Install dependencies
|
||||
echo -e "\n${BLUE}📦 Installing dependencies...${NC}"
|
||||
npm install
|
||||
|
||||
# Build the project
|
||||
echo -e "\n${BLUE}🔨 Building Hanzo Dev...${NC}"
|
||||
|
||||
# Build VS Code extension
|
||||
echo "Building VS Code extension..."
|
||||
npm run compile
|
||||
|
||||
# Build CLI tool
|
||||
echo "Building CLI tool..."
|
||||
cd packages/dev
|
||||
npm install
|
||||
npm run build
|
||||
cd ../..
|
||||
|
||||
# Build MCP server
|
||||
echo "Building MCP server..."
|
||||
cd packages/mcp
|
||||
npm install
|
||||
npm run build
|
||||
cd ../..
|
||||
|
||||
# Create symlink for local development
|
||||
echo -e "\n${BLUE}🔗 Creating local development symlink...${NC}"
|
||||
npm link packages/dev
|
||||
|
||||
# Create hanzo-dev command
|
||||
echo -e "\n${BLUE}🎯 Setting up hanzo-dev command...${NC}"
|
||||
|
||||
# Create a wrapper script
|
||||
cat > /tmp/hanzo-dev-wrapper.js << 'EOF'
|
||||
#!/usr/bin/env node
|
||||
require('./packages/dev/dist/cli/hanzo-dev.js');
|
||||
EOF
|
||||
|
||||
chmod +x /tmp/hanzo-dev-wrapper.js
|
||||
sudo ln -sf "$(pwd)/tmp/hanzo-dev-wrapper.js" /usr/local/bin/hanzo-dev
|
||||
|
||||
echo -e "\n${GREEN}✅ Setup complete!${NC}"
|
||||
echo -e "\n${YELLOW}Quick Start:${NC}"
|
||||
echo "1. Login to Hanzo AI:"
|
||||
echo -e " ${BLUE}hanzo-dev login${NC}"
|
||||
echo ""
|
||||
echo "2. Initialize in your project:"
|
||||
echo -e " ${BLUE}hanzo-dev init${NC}"
|
||||
echo ""
|
||||
echo "3. Run any AI tool:"
|
||||
echo -e " ${BLUE}hanzo-dev run claude "your task"${NC}"
|
||||
echo -e " ${BLUE}hanzo-dev run aider "fix the tests" --auto-commit${NC}"
|
||||
echo -e " ${BLUE}hanzo-dev run openhands "analyze codebase" --worktree${NC}"
|
||||
echo ""
|
||||
echo "4. Compare tools:"
|
||||
echo -e " ${BLUE}hanzo-dev compare "optimize this function"${NC}"
|
||||
echo ""
|
||||
echo "5. Run in background:"
|
||||
echo -e " ${BLUE}hanzo-dev run claude "big refactoring task" --async${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}🎉 Happy coding with Hanzo Dev!${NC}"
|
||||
@@ -0,0 +1,273 @@
|
||||
import { BaseCLITool, CLIToolConfig } from '../common/base-cli';
|
||||
import * as vscode from 'vscode';
|
||||
import { execSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export class AiderCLI extends BaseCLITool {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'Aider CLI',
|
||||
command: 'aider',
|
||||
args: [],
|
||||
env: {},
|
||||
model: 'gpt-4-turbo-preview',
|
||||
apiKeyEnvVar: 'OPENAI_API_KEY',
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7
|
||||
});
|
||||
}
|
||||
|
||||
async install(): Promise<boolean> {
|
||||
try {
|
||||
// Install via pip
|
||||
const hasPip = await this.commandExists('pip3');
|
||||
if (!hasPip) {
|
||||
throw new Error('Python pip is required to install Aider');
|
||||
}
|
||||
|
||||
await this.runCommand('pip3', ['install', 'aider-chat']);
|
||||
|
||||
// Set up git if needed
|
||||
const hasGit = await this.commandExists('git');
|
||||
if (!hasGit) {
|
||||
console.warn('Git is recommended for Aider to work optimally');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to install Aider:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkInstallation(): Promise<boolean> {
|
||||
return this.commandExists('aider');
|
||||
}
|
||||
|
||||
generatePrompt(task: string, context?: any): string {
|
||||
// Aider takes the task as a direct message
|
||||
return task;
|
||||
}
|
||||
|
||||
protected getApiKey(): string {
|
||||
const config = vscode.workspace.getConfiguration('hanzo');
|
||||
const openaiKey = config.get<string>('openaiApiKey');
|
||||
if (openaiKey) return openaiKey;
|
||||
|
||||
const anthropicKey = config.get<string>('anthropicApiKey');
|
||||
if (anthropicKey && this.config.model?.includes('claude')) {
|
||||
return anthropicKey;
|
||||
}
|
||||
|
||||
return process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || '';
|
||||
}
|
||||
|
||||
private async commandExists(command: string): Promise<boolean> {
|
||||
try {
|
||||
execSync(`which ${command}`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Aider-specific methods
|
||||
async startInteractiveSession(files: string[], options?: any): Promise<string> {
|
||||
const args = ['--yes']; // Auto-confirm prompts
|
||||
|
||||
// Add model configuration
|
||||
if (options?.model) {
|
||||
args.push('--model', options.model);
|
||||
} else if (this.config.model) {
|
||||
args.push('--model', this.config.model);
|
||||
}
|
||||
|
||||
// Add files to edit
|
||||
files.forEach(file => args.push(file));
|
||||
|
||||
// Additional options
|
||||
if (options?.readOnly) {
|
||||
args.push('--read-only');
|
||||
}
|
||||
|
||||
if (options?.autoCommit !== false) {
|
||||
args.push('--auto-commits');
|
||||
}
|
||||
|
||||
if (options?.noPretty) {
|
||||
args.push('--no-pretty');
|
||||
}
|
||||
|
||||
if (options?.showDiffs !== false) {
|
||||
args.push('--show-diffs');
|
||||
}
|
||||
|
||||
const result = await this.execute('', {
|
||||
env: {
|
||||
...process.env,
|
||||
AIDER_ARGS: args.join(' ')
|
||||
}
|
||||
});
|
||||
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async editFiles(files: string[], instruction: string, options?: any): Promise<string> {
|
||||
const args = [
|
||||
'--yes',
|
||||
'--message', instruction
|
||||
];
|
||||
|
||||
// Add model
|
||||
if (options?.model || this.config.model) {
|
||||
args.push('--model', options?.model || this.config.model);
|
||||
}
|
||||
|
||||
// Add files
|
||||
files.forEach(file => args.push(file));
|
||||
|
||||
// Auto-commit by default
|
||||
if (options?.autoCommit !== false) {
|
||||
args.push('--auto-commits');
|
||||
}
|
||||
|
||||
const result = await this.execute('', {
|
||||
env: {
|
||||
...process.env,
|
||||
AIDER_COMMAND: instruction,
|
||||
AIDER_ARGS: args.join(' ')
|
||||
}
|
||||
});
|
||||
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async addFiles(files: string[], readOnly: boolean = false): Promise<string> {
|
||||
const args = ['--add'];
|
||||
|
||||
if (readOnly) {
|
||||
args.push('--read-only');
|
||||
}
|
||||
|
||||
files.forEach(file => args.push(file));
|
||||
|
||||
const result = await this.execute('', {
|
||||
env: {
|
||||
...process.env,
|
||||
AIDER_ARGS: args.join(' ')
|
||||
}
|
||||
});
|
||||
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async runWithGitRepo(task: string, repoPath: string, options?: any): Promise<string> {
|
||||
const args = [
|
||||
'--yes',
|
||||
'--message', task
|
||||
];
|
||||
|
||||
if (options?.model) {
|
||||
args.push('--model', options.model);
|
||||
}
|
||||
|
||||
if (options?.darkMode) {
|
||||
args.push('--dark-mode');
|
||||
}
|
||||
|
||||
if (options?.stream !== false) {
|
||||
args.push('--stream');
|
||||
}
|
||||
|
||||
const result = await this.execute('', {
|
||||
cwd: repoPath,
|
||||
env: {
|
||||
...process.env,
|
||||
AIDER_ARGS: args.join(' ')
|
||||
}
|
||||
});
|
||||
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async askQuestion(question: string, context?: string[]): Promise<string> {
|
||||
const args = [
|
||||
'--yes',
|
||||
'--message', question,
|
||||
'--no-auto-commits' // Don't commit for questions
|
||||
];
|
||||
|
||||
// Add context files as read-only
|
||||
if (context && context.length > 0) {
|
||||
args.push('--read-only');
|
||||
context.forEach(file => args.push(file));
|
||||
}
|
||||
|
||||
const result = await this.execute('', {
|
||||
env: {
|
||||
...process.env,
|
||||
AIDER_ARGS: args.join(' ')
|
||||
}
|
||||
});
|
||||
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async useAlternativeModel(model: string, task: string, files?: string[]): Promise<string> {
|
||||
const args = [
|
||||
'--yes',
|
||||
'--model', model,
|
||||
'--message', task
|
||||
];
|
||||
|
||||
if (files) {
|
||||
files.forEach(file => args.push(file));
|
||||
}
|
||||
|
||||
const result = await this.execute('', {
|
||||
env: {
|
||||
...process.env,
|
||||
AIDER_ARGS: args.join(' ')
|
||||
}
|
||||
});
|
||||
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async showCostEstimate(files: string[]): Promise<string> {
|
||||
const args = ['--show-cost', '--dry-run'];
|
||||
files.forEach(file => args.push(file));
|
||||
|
||||
const result = await this.execute('', {
|
||||
env: {
|
||||
...process.env,
|
||||
AIDER_ARGS: args.join(' ')
|
||||
}
|
||||
});
|
||||
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async applyChangesFromUrl(url: string, files?: string[]): Promise<string> {
|
||||
const task = `Apply the changes described in this URL: ${url}`;
|
||||
|
||||
const args = [
|
||||
'--yes',
|
||||
'--message', task
|
||||
];
|
||||
|
||||
if (files) {
|
||||
files.forEach(file => args.push(file));
|
||||
}
|
||||
|
||||
const result = await this.execute('', {
|
||||
env: {
|
||||
...process.env,
|
||||
AIDER_ARGS: args.join(' ')
|
||||
}
|
||||
});
|
||||
|
||||
return result.output || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as http from 'http';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export interface HanzoAuthConfig {
|
||||
apiUrl: string;
|
||||
iamUrl: string;
|
||||
clientId: string;
|
||||
scope: string;
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
export interface HanzoCredentials {
|
||||
apiKey?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
userId?: string;
|
||||
email?: string;
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface APIKeyInfo {
|
||||
name: string;
|
||||
key: string;
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
masked?: string;
|
||||
}
|
||||
|
||||
export class HanzoAuth extends EventEmitter {
|
||||
private config: HanzoAuthConfig;
|
||||
private credentialsPath: string;
|
||||
private settingsPath: string;
|
||||
private credentials: HanzoCredentials | null = null;
|
||||
private server?: http.Server;
|
||||
|
||||
constructor(config: Partial<HanzoAuthConfig> = {}) {
|
||||
super();
|
||||
this.config = {
|
||||
apiUrl: config.apiUrl || 'https://api.hanzo.ai',
|
||||
iamUrl: config.iamUrl || 'https://iam.hanzo.ai',
|
||||
clientId: config.clientId || 'hanzo-dev-cli',
|
||||
scope: config.scope || 'api:access tools:manage',
|
||||
configPath: config.configPath || path.join(os.homedir(), '.hanzo')
|
||||
};
|
||||
|
||||
// Ensure config directory exists
|
||||
fs.mkdirSync(this.config.configPath!, { recursive: true });
|
||||
|
||||
this.credentialsPath = path.join(this.config.configPath!, 'credentials.json');
|
||||
this.settingsPath = path.join(this.config.configPath!, 'settings.json');
|
||||
|
||||
// Load existing credentials
|
||||
this.loadCredentials();
|
||||
}
|
||||
|
||||
private loadCredentials(): void {
|
||||
try {
|
||||
if (fs.existsSync(this.credentialsPath)) {
|
||||
const data = fs.readFileSync(this.credentialsPath, 'utf-8');
|
||||
this.credentials = JSON.parse(data);
|
||||
|
||||
// Check if token is expired
|
||||
if (this.credentials?.expiresAt && this.credentials.expiresAt < Date.now()) {
|
||||
this.emit('token:expired');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load credentials:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private saveCredentials(): void {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
this.credentialsPath,
|
||||
JSON.stringify(this.credentials, null, 2),
|
||||
{ mode: 0o600 } // Secure file permissions
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to save credentials:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async login(): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Generate PKCE challenge
|
||||
const codeVerifier = this.generateCodeVerifier();
|
||||
const codeChallenge = this.generateCodeChallenge(codeVerifier);
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Start local server for OAuth callback
|
||||
const port = 51234;
|
||||
this.server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, `http://localhost:${port}`);
|
||||
|
||||
if (url.pathname === '/callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const returnedState = url.searchParams.get('state');
|
||||
|
||||
if (returnedState !== state) {
|
||||
res.writeHead(400);
|
||||
res.end('Invalid state parameter');
|
||||
this.server?.close();
|
||||
reject(new Error('Invalid state parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
// Exchange code for tokens
|
||||
try {
|
||||
await this.exchangeCodeForTokens(code, codeVerifier);
|
||||
|
||||
// Success response
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Hanzo Dev - Login Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #1a1a1a; color: white; }
|
||||
.container { text-align: center; }
|
||||
.success { color: #4ade80; font-size: 48px; margin-bottom: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success">✓</div>
|
||||
<h1>Login Successful!</h1>
|
||||
<p>You can now close this window and return to your terminal.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
this.server?.close();
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
res.writeHead(500);
|
||||
res.end('Failed to exchange code for tokens');
|
||||
this.server?.close();
|
||||
reject(error);
|
||||
}
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('No authorization code received');
|
||||
this.server?.close();
|
||||
reject(new Error('No authorization code received'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(port, () => {
|
||||
// Build authorization URL
|
||||
const authUrl = new URL('/oauth/authorize', this.config.iamUrl);
|
||||
authUrl.searchParams.set('client_id', this.config.clientId);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`);
|
||||
authUrl.searchParams.set('scope', this.config.scope);
|
||||
authUrl.searchParams.set('state', state);
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
// Open browser
|
||||
this.openBrowser(authUrl.toString());
|
||||
|
||||
console.log('Opening browser for authentication...');
|
||||
console.log('If browser doesn\'t open, visit:', authUrl.toString());
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(() => {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
reject(new Error('Login timeout'));
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
private async exchangeCodeForTokens(code: string, codeVerifier: string): Promise<void> {
|
||||
const response = await fetch(`${this.config.iamUrl}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: this.config.clientId,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: 'http://localhost:51234/callback'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to exchange code: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
this.credentials = {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresAt: Date.now() + (data.expires_in * 1000),
|
||||
...this.credentials // Preserve existing settings
|
||||
};
|
||||
|
||||
// Fetch user info
|
||||
await this.fetchUserInfo();
|
||||
|
||||
// Fetch and sync API keys
|
||||
await this.syncAPIKeys();
|
||||
|
||||
this.saveCredentials();
|
||||
this.emit('login:success', this.credentials);
|
||||
}
|
||||
|
||||
private async fetchUserInfo(): Promise<void> {
|
||||
if (!this.credentials?.accessToken) return;
|
||||
|
||||
const response = await fetch(`${this.config.iamUrl}/api/user`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.credentials.accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
this.credentials.userId = user.id;
|
||||
this.credentials.email = user.email;
|
||||
}
|
||||
}
|
||||
|
||||
async syncAPIKeys(): Promise<APIKeyInfo[]> {
|
||||
if (!this.credentials?.accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.config.apiUrl}/v1/api-keys`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.credentials.accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch API keys: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const apiKeys = await response.json();
|
||||
|
||||
// Store API keys in settings (encrypted)
|
||||
const settings = this.loadSettings();
|
||||
settings.apiKeys = apiKeys.map((key: any) => ({
|
||||
name: key.name,
|
||||
provider: key.provider,
|
||||
enabled: key.enabled,
|
||||
// Store encrypted version locally
|
||||
encryptedKey: this.encryptData(key.key),
|
||||
masked: key.key.substring(0, 8) + '...' + key.key.substring(key.key.length - 4)
|
||||
}));
|
||||
|
||||
this.saveSettings(settings);
|
||||
this.emit('apikeys:synced', apiKeys.length);
|
||||
|
||||
return apiKeys;
|
||||
}
|
||||
|
||||
getAPIKey(provider: string): string | null {
|
||||
const settings = this.loadSettings();
|
||||
const keyInfo = settings.apiKeys?.find((k: any) =>
|
||||
k.provider === provider && k.enabled
|
||||
);
|
||||
|
||||
if (!keyInfo?.encryptedKey) {
|
||||
// Try environment variable as fallback
|
||||
const envKey = `${provider.toUpperCase()}_API_KEY`;
|
||||
return process.env[envKey] || null;
|
||||
}
|
||||
|
||||
return this.decryptData(keyInfo.encryptedKey);
|
||||
}
|
||||
|
||||
async refreshToken(): Promise<boolean> {
|
||||
if (!this.credentials?.refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.iamUrl}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: this.credentials.refreshToken,
|
||||
client_id: this.config.clientId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to refresh token: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
this.credentials = {
|
||||
...this.credentials,
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || this.credentials.refreshToken,
|
||||
expiresAt: Date.now() + (data.expires_in * 1000)
|
||||
};
|
||||
|
||||
this.saveCredentials();
|
||||
this.emit('token:refreshed');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.emit('token:refresh:failed', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
if (this.credentials?.accessToken) {
|
||||
try {
|
||||
await fetch(`${this.config.iamUrl}/oauth/revoke`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.credentials.accessToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: this.credentials.refreshToken || this.credentials.accessToken
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear credentials but keep settings
|
||||
this.credentials = null;
|
||||
if (fs.existsSync(this.credentialsPath)) {
|
||||
fs.unlinkSync(this.credentialsPath);
|
||||
}
|
||||
|
||||
this.emit('logout');
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
if (!this.credentials?.accessToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (this.credentials.expiresAt && this.credentials.expiresAt < Date.now()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getCredentials(): HanzoCredentials | null {
|
||||
return this.credentials;
|
||||
}
|
||||
|
||||
async makeAuthenticatedRequest(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
if (!this.isAuthenticated()) {
|
||||
// Try to refresh token
|
||||
if (this.credentials?.refreshToken) {
|
||||
const refreshed = await this.refreshToken();
|
||||
if (!refreshed) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${this.credentials!.accessToken}`
|
||||
};
|
||||
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
|
||||
private loadSettings(): Record<string, any> {
|
||||
try {
|
||||
if (fs.existsSync(this.settingsPath)) {
|
||||
return JSON.parse(fs.readFileSync(this.settingsPath, 'utf-8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private saveSettings(settings: Record<string, any>): void {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
this.settingsPath,
|
||||
JSON.stringify(settings, null, 2),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private encryptData(data: string): string {
|
||||
// Use machine ID as encryption key
|
||||
const key = crypto.scryptSync(this.getMachineId(), 'salt', 32);
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
||||
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
private decryptData(encryptedData: string): string {
|
||||
const [ivHex, encrypted] = encryptedData.split(':');
|
||||
const key = crypto.scryptSync(this.getMachineId(), 'salt', 32);
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
private getMachineId(): string {
|
||||
// Simple machine ID based on hostname and platform
|
||||
return crypto.createHash('sha256')
|
||||
.update(os.hostname())
|
||||
.update(os.platform())
|
||||
.update(os.homedir())
|
||||
.digest('hex')
|
||||
.substring(0, 32);
|
||||
}
|
||||
|
||||
private generateCodeVerifier(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
private generateCodeChallenge(verifier: string): string {
|
||||
return crypto.createHash('sha256')
|
||||
.update(verifier)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
private openBrowser(url: string): void {
|
||||
const platform = os.platform();
|
||||
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
spawn('open', [url], { detached: true });
|
||||
} else if (platform === 'win32') {
|
||||
spawn('start', ['', url], { shell: true, detached: true });
|
||||
} else {
|
||||
spawn('xdg-open', [url], { detached: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open browser:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { BaseCLITool, CLIToolConfig } from '../common/base-cli';
|
||||
import * as vscode from 'vscode';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export class ClaudeCLI extends BaseCLITool {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'Claude CLI',
|
||||
command: 'claude',
|
||||
args: [],
|
||||
env: {},
|
||||
model: 'claude-3-opus-20240229',
|
||||
apiKeyEnvVar: 'ANTHROPIC_API_KEY',
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7
|
||||
});
|
||||
}
|
||||
|
||||
async install(): Promise<boolean> {
|
||||
try {
|
||||
// Check if pip/pipx is available
|
||||
const hasPipx = await this.commandExists('pipx');
|
||||
const hasPip = await this.commandExists('pip');
|
||||
|
||||
if (hasPipx) {
|
||||
await this.runCommand('pipx', ['install', 'anthropic']);
|
||||
} else if (hasPip) {
|
||||
await this.runCommand('pip', ['install', '--user', 'anthropic']);
|
||||
} else {
|
||||
throw new Error('Neither pip nor pipx found. Please install Python first.');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to install Claude CLI:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkInstallation(): Promise<boolean> {
|
||||
return this.commandExists('claude');
|
||||
}
|
||||
|
||||
generatePrompt(task: string, context?: any): string {
|
||||
const systemPrompt = context?.systemPrompt || 'You are a helpful AI assistant.';
|
||||
const prompt = `
|
||||
System: ${systemPrompt}
|
||||
|
||||
Task: ${task}
|
||||
|
||||
Please provide a detailed response.
|
||||
`;
|
||||
return prompt;
|
||||
}
|
||||
|
||||
protected getApiKey(): string {
|
||||
// First check VS Code settings
|
||||
const config = vscode.workspace.getConfiguration('hanzo');
|
||||
const anthropicKey = config.get<string>('anthropicApiKey');
|
||||
if (anthropicKey) return anthropicKey;
|
||||
|
||||
// Fall back to environment variable
|
||||
return process.env.ANTHROPIC_API_KEY || '';
|
||||
}
|
||||
|
||||
private async commandExists(command: string): Promise<boolean> {
|
||||
try {
|
||||
execSync(`which ${command}`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Claude-specific methods
|
||||
async chat(message: string, conversationId?: string): Promise<string> {
|
||||
const args = ['chat'];
|
||||
if (conversationId) {
|
||||
args.push('--conversation', conversationId);
|
||||
}
|
||||
args.push('--message', message);
|
||||
|
||||
const result = await this.execute('', {
|
||||
env: { ...process.env, CLAUDE_ARGS: args.join(' ') }
|
||||
});
|
||||
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async completeCode(code: string, language: string): Promise<string> {
|
||||
const prompt = `Complete this ${language} code:\n\n${code}`;
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async reviewCode(code: string, language: string): Promise<string> {
|
||||
const prompt = `Review this ${language} code for bugs, security issues, and improvements:\n\n${code}`;
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async generateTests(code: string, language: string, framework?: string): Promise<string> {
|
||||
const prompt = `Generate comprehensive unit tests for this ${language} code${framework ? ` using ${framework}` : ''}:\n\n${code}`;
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import { BaseCLITool } from './common/base-cli';
|
||||
import { ClaudeCLI } from './claude/claude-cli';
|
||||
import { CodexCLI } from './codex/codex-cli';
|
||||
import { GeminiCLI } from './gemini/gemini-cli';
|
||||
import { OpenHandsCLI } from './openhands/openhands-cli';
|
||||
import { AiderCLI } from './aider/aider-cli';
|
||||
import { AsyncToolWrapper, AsyncToolResult } from './platform/async-tool-wrapper';
|
||||
import { HanzoAuth } from '../auth/hanzo-auth';
|
||||
|
||||
export type CLIToolType = 'claude' | 'codex' | 'gemini' | 'openhands' | 'aider';
|
||||
|
||||
export interface CLIToolTask {
|
||||
tool: CLIToolType;
|
||||
task: string;
|
||||
context?: any;
|
||||
directory?: string;
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
export class CLIToolManager {
|
||||
private tools: Map<CLIToolType, BaseCLITool> = new Map();
|
||||
private asyncWrapper: AsyncToolWrapper;
|
||||
private outputChannel: vscode.OutputChannel;
|
||||
private initialized: boolean = false;
|
||||
private asyncJobs: Map<string, { toolType: CLIToolType; task: string }> = new Map();
|
||||
private auth: HanzoAuth;
|
||||
|
||||
constructor(auth?: HanzoAuth) {
|
||||
this.outputChannel = vscode.window.createOutputChannel('Hanzo Dev Tools');
|
||||
this.asyncWrapper = new AsyncToolWrapper({
|
||||
idleTimeout: 5 * 60 * 1000, // 5 minutes
|
||||
maxRuntime: 30 * 60 * 1000, // 30 minutes
|
||||
persistResults: true,
|
||||
resultPath: path.join(process.cwd(), '.hanzo-dev', 'async-results')
|
||||
});
|
||||
|
||||
this.auth = auth || new HanzoAuth();
|
||||
this.setupAsyncHandlers();
|
||||
this.setupAPIKeys();
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
this.outputChannel.appendLine('Initializing CLI tools...');
|
||||
|
||||
// Initialize all CLI tools
|
||||
const claudeCLI = new ClaudeCLI();
|
||||
const codexCLI = new CodexCLI();
|
||||
const geminiCLI = new GeminiCLI();
|
||||
const openhandsCLI = new OpenHandsCLI();
|
||||
const aiderCLI = new AiderCLI();
|
||||
|
||||
// Set up event listeners
|
||||
[claudeCLI, codexCLI, geminiCLI, openhandsCLI, aiderCLI].forEach(tool => {
|
||||
tool.on('output', (data) => {
|
||||
this.outputChannel.append(`[${tool.getConfig().name}] ${data}`);
|
||||
});
|
||||
|
||||
tool.on('error', (error) => {
|
||||
this.outputChannel.appendLine(`[${tool.getConfig().name}] ERROR: ${error}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize tools
|
||||
await Promise.all([
|
||||
claudeCLI.initialize(),
|
||||
codexCLI.initialize(),
|
||||
geminiCLI.initialize(),
|
||||
openhandsCLI.initialize(),
|
||||
aiderCLI.initialize()
|
||||
]);
|
||||
|
||||
this.tools.set('claude', claudeCLI);
|
||||
this.tools.set('codex', codexCLI);
|
||||
this.tools.set('gemini', geminiCLI);
|
||||
this.tools.set('openhands', openhandsCLI);
|
||||
this.tools.set('aider', aiderCLI);
|
||||
|
||||
this.initialized = true;
|
||||
this.outputChannel.appendLine('CLI tools initialized successfully!');
|
||||
}
|
||||
|
||||
private async setupAPIKeys(): Promise<void> {
|
||||
// Set up API keys from Hanzo Auth
|
||||
if (this.auth.isAuthenticated()) {
|
||||
// Claude/Anthropic
|
||||
const anthropicKey = this.auth.getAPIKey('anthropic');
|
||||
if (anthropicKey) {
|
||||
process.env.ANTHROPIC_API_KEY = anthropicKey;
|
||||
}
|
||||
|
||||
// OpenAI/Codex
|
||||
const openaiKey = this.auth.getAPIKey('openai');
|
||||
if (openaiKey) {
|
||||
process.env.OPENAI_API_KEY = openaiKey;
|
||||
}
|
||||
|
||||
// Google/Gemini
|
||||
const googleKey = this.auth.getAPIKey('google');
|
||||
if (googleKey) {
|
||||
process.env.GOOGLE_API_KEY = googleKey;
|
||||
}
|
||||
|
||||
this.outputChannel.appendLine('API keys loaded from Hanzo Auth');
|
||||
}
|
||||
}
|
||||
|
||||
async executeTool(toolType: CLIToolType, task: string, options?: any): Promise<any> {
|
||||
await this.initialize();
|
||||
|
||||
const tool = this.tools.get(toolType);
|
||||
if (!tool) {
|
||||
throw new Error(`Unknown tool type: ${toolType}`);
|
||||
}
|
||||
|
||||
if (!tool.isReady()) {
|
||||
throw new Error(`${toolType} is not ready. Please check installation.`);
|
||||
}
|
||||
|
||||
this.outputChannel.appendLine(`\n[${toolType}] Executing: ${task}`);
|
||||
const result = await tool.execute(task, options);
|
||||
|
||||
if (result.success) {
|
||||
this.outputChannel.appendLine(`[${toolType}] Completed successfully in ${result.duration}ms`);
|
||||
} else {
|
||||
this.outputChannel.appendLine(`[${toolType}] Failed: ${result.error}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async executeParallel(tasks: CLIToolTask[]): Promise<Map<string, any>> {
|
||||
await this.initialize();
|
||||
|
||||
const results = new Map<string, any>();
|
||||
const promises = tasks.map(async (taskConfig, index) => {
|
||||
const tool = this.tools.get(taskConfig.tool);
|
||||
if (!tool) {
|
||||
results.set(`task-${index}`, {
|
||||
error: `Unknown tool: ${taskConfig.tool}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.execute(taskConfig.task, {
|
||||
cwd: taskConfig.directory
|
||||
});
|
||||
results.set(`task-${index}`, result);
|
||||
} catch (error) {
|
||||
results.set(`task-${index}`, {
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
return results;
|
||||
}
|
||||
|
||||
async compareResults(task: string, tools: CLIToolType[] = ['claude', 'codex', 'gemini']): Promise<Map<CLIToolType, any>> {
|
||||
await this.initialize();
|
||||
|
||||
const results = new Map<CLIToolType, any>();
|
||||
|
||||
this.outputChannel.appendLine(`\n=== Comparing results for: ${task} ===`);
|
||||
|
||||
for (const toolType of tools) {
|
||||
const tool = this.tools.get(toolType);
|
||||
if (!tool || !tool.isReady()) {
|
||||
results.set(toolType, {
|
||||
error: `${toolType} not available`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.execute(task);
|
||||
results.set(toolType, result);
|
||||
this.outputChannel.appendLine(`\n[${toolType}] Result:\n${result.output}`);
|
||||
} catch (error) {
|
||||
results.set(toolType, {
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Specialized methods for common tasks
|
||||
async generateCode(description: string, language: string, preferredTool?: CLIToolType): Promise<string> {
|
||||
const tool = preferredTool || 'codex';
|
||||
const codexCLI = this.tools.get(tool) as CodexCLI;
|
||||
|
||||
if (codexCLI && codexCLI.generateCode) {
|
||||
return codexCLI.generateCode(description, language);
|
||||
}
|
||||
|
||||
// Fallback to general execution
|
||||
const task = `Generate ${language} code for: ${description}`;
|
||||
const result = await this.executeTool(tool, task);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async reviewCode(code: string, language: string, preferredTool?: CLIToolType): Promise<string> {
|
||||
const tool = preferredTool || 'claude';
|
||||
const claudeCLI = this.tools.get(tool) as ClaudeCLI;
|
||||
|
||||
if (claudeCLI && claudeCLI.reviewCode) {
|
||||
return claudeCLI.reviewCode(code, language);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
const task = `Review this ${language} code:\n${code}`;
|
||||
const result = await this.executeTool(tool, task);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async debugCode(code: string, error: string, language: string, preferredTool?: CLIToolType): Promise<string> {
|
||||
const tool = preferredTool || 'gemini';
|
||||
const geminiCLI = this.tools.get(tool) as GeminiCLI;
|
||||
|
||||
if (geminiCLI && geminiCLI.debugCode) {
|
||||
return geminiCLI.debugCode(code, error, language);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
const task = `Debug this ${language} code with error: ${error}\n\nCode:\n${code}`;
|
||||
const result = await this.executeTool(tool, task);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
getAvailableTools(): CLIToolType[] {
|
||||
const available: CLIToolType[] = [];
|
||||
|
||||
for (const [type, tool] of this.tools) {
|
||||
if (tool.isReady()) {
|
||||
available.push(type);
|
||||
}
|
||||
}
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
showOutput(): void {
|
||||
this.outputChannel.show();
|
||||
}
|
||||
|
||||
private setupAsyncHandlers(): void {
|
||||
this.asyncWrapper.on('job:created', (job) => {
|
||||
this.outputChannel.appendLine(`[Async] Job created: ${job.id} (${job.toolName})`);
|
||||
});
|
||||
|
||||
this.asyncWrapper.on('job:completed', (job) => {
|
||||
this.outputChannel.appendLine(`[Async] Job completed: ${job.id} (${job.toolName})`);
|
||||
const jobInfo = this.asyncJobs.get(job.id);
|
||||
if (jobInfo) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Hanzo Dev: ${jobInfo.toolType} task completed`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.asyncWrapper.on('job:failed', (job) => {
|
||||
this.outputChannel.appendLine(`[Async] Job failed: ${job.id} (${job.toolName})`);
|
||||
});
|
||||
|
||||
this.asyncWrapper.on('job:idle', (job) => {
|
||||
this.outputChannel.appendLine(`[Async] Job idle: ${job.id} (${job.toolName})`);
|
||||
});
|
||||
}
|
||||
|
||||
// Async execution methods
|
||||
async executeToolAsync(toolType: CLIToolType, task: string, options?: any): Promise<string> {
|
||||
await this.initialize();
|
||||
|
||||
const tool = this.tools.get(toolType);
|
||||
if (!tool) {
|
||||
throw new Error(`Unknown tool type: ${toolType}`);
|
||||
}
|
||||
|
||||
if (!tool.isReady()) {
|
||||
throw new Error(`${toolType} is not ready. Please check installation.`);
|
||||
}
|
||||
|
||||
const command = tool.getConfig().command;
|
||||
const args = [...(tool.getConfig().args || []), tool.generatePrompt(task, options)];
|
||||
|
||||
const jobId = await this.asyncWrapper.createAsyncTool(
|
||||
toolType,
|
||||
command,
|
||||
args,
|
||||
{ toolType, task, options }
|
||||
);
|
||||
|
||||
this.asyncJobs.set(jobId, { toolType, task });
|
||||
|
||||
this.outputChannel.appendLine(`\n[${toolType}] Started async job: ${jobId}`);
|
||||
vscode.window.showInformationMessage(
|
||||
`Hanzo Dev: Started ${toolType} task (Job ID: ${jobId.substring(0, 8)}...)`
|
||||
);
|
||||
|
||||
return jobId;
|
||||
}
|
||||
|
||||
async queryAsyncJob(jobId: string): Promise<AsyncToolResult | null> {
|
||||
return this.asyncWrapper.queryJob(jobId);
|
||||
}
|
||||
|
||||
async waitForAsyncJob(jobId: string, timeout?: number): Promise<AsyncToolResult | null> {
|
||||
return this.asyncWrapper.waitForJob(jobId, timeout);
|
||||
}
|
||||
|
||||
keepAsyncJobAlive(jobId: string): void {
|
||||
this.asyncWrapper.keepAlive(jobId);
|
||||
}
|
||||
|
||||
sendInputToAsyncJob(jobId: string, input: string): boolean {
|
||||
return this.asyncWrapper.sendInput(jobId, input);
|
||||
}
|
||||
|
||||
cancelAsyncJob(jobId: string): void {
|
||||
this.asyncWrapper.cancelJob(jobId);
|
||||
this.asyncJobs.delete(jobId);
|
||||
}
|
||||
|
||||
getActiveAsyncJobs(): Array<{ jobId: string; toolType: CLIToolType; task: string }> {
|
||||
const activeJobs = this.asyncWrapper.getActiveJobs();
|
||||
return activeJobs.map(job => {
|
||||
const jobInfo = this.asyncJobs.get(job.id);
|
||||
return {
|
||||
jobId: job.id,
|
||||
toolType: jobInfo?.toolType || 'unknown' as CLIToolType,
|
||||
task: jobInfo?.task || 'Unknown task'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Execute multiple tools asynchronously and compare results
|
||||
async executeParallelAsync(
|
||||
tasks: CLIToolTask[]
|
||||
): Promise<Map<string, string>> {
|
||||
await this.initialize();
|
||||
|
||||
const jobIds = new Map<string, string>();
|
||||
|
||||
for (const taskConfig of tasks) {
|
||||
try {
|
||||
const jobId = await this.executeToolAsync(
|
||||
taskConfig.tool,
|
||||
taskConfig.task,
|
||||
{
|
||||
cwd: taskConfig.directory,
|
||||
...taskConfig.context
|
||||
}
|
||||
);
|
||||
jobIds.set(`${taskConfig.tool}-${Date.now()}`, jobId);
|
||||
} catch (error) {
|
||||
this.outputChannel.appendLine(`Failed to start ${taskConfig.tool}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return jobIds;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
// Cancel all async jobs
|
||||
for (const jobId of this.asyncJobs.keys()) {
|
||||
this.asyncWrapper.cancelJob(jobId);
|
||||
}
|
||||
|
||||
for (const tool of this.tools.values()) {
|
||||
tool.stop();
|
||||
}
|
||||
|
||||
this.asyncWrapper.dispose();
|
||||
this.outputChannel.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { BaseCLITool, CLIToolConfig } from '../common/base-cli';
|
||||
import * as vscode from 'vscode';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export class CodexCLI extends BaseCLITool {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'Codex CLI',
|
||||
command: 'openai',
|
||||
args: ['api', 'completions.create'],
|
||||
env: {},
|
||||
model: 'code-davinci-002',
|
||||
apiKeyEnvVar: 'OPENAI_API_KEY',
|
||||
maxTokens: 2048,
|
||||
temperature: 0.3
|
||||
});
|
||||
}
|
||||
|
||||
async install(): Promise<boolean> {
|
||||
try {
|
||||
// Install OpenAI CLI
|
||||
const hasNpm = await this.commandExists('npm');
|
||||
const hasYarn = await this.commandExists('yarn');
|
||||
|
||||
if (hasNpm) {
|
||||
await this.runCommand('npm', ['install', '-g', 'openai-cli']);
|
||||
} else if (hasYarn) {
|
||||
await this.runCommand('yarn', ['global', 'add', 'openai-cli']);
|
||||
} else {
|
||||
// Try Python installation
|
||||
await this.runCommand('pip', ['install', '--user', 'openai']);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to install Codex CLI:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkInstallation(): Promise<boolean> {
|
||||
return this.commandExists('openai') || this.commandExists('python');
|
||||
}
|
||||
|
||||
generatePrompt(task: string, context?: any): string {
|
||||
const language = context?.language || 'typescript';
|
||||
const purpose = context?.purpose || 'general';
|
||||
|
||||
const prompt = `
|
||||
Language: ${language}
|
||||
Purpose: ${purpose}
|
||||
|
||||
Task: ${task}
|
||||
|
||||
Generate high-quality, production-ready code with comments.
|
||||
`;
|
||||
return prompt;
|
||||
}
|
||||
|
||||
protected getApiKey(): string {
|
||||
const config = vscode.workspace.getConfiguration('hanzo');
|
||||
const openaiKey = config.get<string>('openaiApiKey');
|
||||
if (openaiKey) return openaiKey;
|
||||
|
||||
return process.env.OPENAI_API_KEY || '';
|
||||
}
|
||||
|
||||
private async commandExists(command: string): Promise<boolean> {
|
||||
try {
|
||||
execSync(`which ${command}`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Codex-specific methods
|
||||
async generateCode(description: string, language: string): Promise<string> {
|
||||
const prompt = `# ${language}\n# ${description}\n\n`;
|
||||
const result = await this.execute(prompt, {
|
||||
env: {
|
||||
...process.env,
|
||||
OPENAI_MODEL: 'code-davinci-002',
|
||||
OPENAI_MAX_TOKENS: '2048',
|
||||
OPENAI_TEMPERATURE: '0.3'
|
||||
}
|
||||
});
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async explainCode(code: string, language: string): Promise<string> {
|
||||
const prompt = `Explain this ${language} code in detail:\n\n${code}\n\nExplanation:`;
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async optimizeCode(code: string, language: string): Promise<string> {
|
||||
const prompt = `Optimize this ${language} code for performance and readability:\n\n${code}\n\nOptimized version:`;
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async convertCode(code: string, fromLang: string, toLang: string): Promise<string> {
|
||||
const prompt = `Convert this ${fromLang} code to ${toLang}:\n\n${code}\n\n${toLang} version:`;
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async generateDocumentation(code: string, language: string): Promise<string> {
|
||||
const prompt = `Generate comprehensive documentation for this ${language} code:\n\n${code}\n\nDocumentation:`;
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async findBugs(code: string, language: string): Promise<string> {
|
||||
const prompt = `Find potential bugs and issues in this ${language} code:\n\n${code}\n\nBugs found:`;
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { spawn, ChildProcess, SpawnOptions } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface CLIToolConfig {
|
||||
name: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
model?: string;
|
||||
apiKeyEnvVar?: string;
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface CLIToolResponse {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
exitCode?: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export abstract class BaseCLITool extends EventEmitter {
|
||||
protected config: CLIToolConfig;
|
||||
protected process?: ChildProcess;
|
||||
protected isInstalled: boolean = false;
|
||||
protected output: string[] = [];
|
||||
protected startTime?: number;
|
||||
|
||||
constructor(config: CLIToolConfig) {
|
||||
super();
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
abstract install(): Promise<boolean>;
|
||||
abstract checkInstallation(): Promise<boolean>;
|
||||
abstract generatePrompt(task: string, context?: any): string;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.isInstalled = await this.checkInstallation();
|
||||
if (!this.isInstalled) {
|
||||
console.log(`${this.config.name} not installed. Installing...`);
|
||||
this.isInstalled = await this.install();
|
||||
}
|
||||
}
|
||||
|
||||
async execute(task: string, options: SpawnOptions = {}): Promise<CLIToolResponse> {
|
||||
if (!this.isInstalled) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
this.startTime = Date.now();
|
||||
this.output = [];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const prompt = this.generatePrompt(task);
|
||||
const args = [...(this.config.args || []), prompt];
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
...this.config.env,
|
||||
[this.config.apiKeyEnvVar || 'API_KEY']: this.getApiKey()
|
||||
};
|
||||
|
||||
this.process = spawn(this.config.command, args, {
|
||||
...options,
|
||||
env
|
||||
});
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
this.output.push(output);
|
||||
this.emit('output', output);
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
const error = data.toString();
|
||||
this.emit('error', error);
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
const duration = Date.now() - this.startTime!;
|
||||
resolve({
|
||||
success: code === 0,
|
||||
output: this.output.join(''),
|
||||
exitCode: code || 0,
|
||||
duration
|
||||
});
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
resolve({
|
||||
success: false,
|
||||
error: err.message,
|
||||
duration: Date.now() - this.startTime!
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async executeWithFile(task: string, filePath: string): Promise<CLIToolResponse> {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const enhancedTask = `${task}\n\nFile: ${filePath}\nContent:\n${fileContent}`;
|
||||
return this.execute(enhancedTask);
|
||||
}
|
||||
|
||||
async executeInDirectory(task: string, directory: string): Promise<CLIToolResponse> {
|
||||
return this.execute(task, { cwd: directory });
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.process) {
|
||||
this.process.kill();
|
||||
}
|
||||
}
|
||||
|
||||
protected getApiKey(): string {
|
||||
// Override in subclasses to get API key from appropriate source
|
||||
return process.env[this.config.apiKeyEnvVar || 'API_KEY'] || '';
|
||||
}
|
||||
|
||||
protected async runCommand(command: string, args: string[] = []): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args, { shell: true });
|
||||
let output = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(output.trim());
|
||||
} else {
|
||||
reject(new Error(`Command failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getConfig(): CLIToolConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.isInstalled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { BaseCLITool, CLIToolConfig } from '../common/base-cli';
|
||||
import * as vscode from 'vscode';
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export class GeminiCLI extends BaseCLITool {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'Gemini CLI',
|
||||
command: 'gemini',
|
||||
args: [],
|
||||
env: {},
|
||||
model: 'gemini-pro',
|
||||
apiKeyEnvVar: 'GOOGLE_API_KEY',
|
||||
maxTokens: 8192,
|
||||
temperature: 0.7
|
||||
});
|
||||
}
|
||||
|
||||
async install(): Promise<boolean> {
|
||||
try {
|
||||
// Install Google AI Python SDK or Node.js package
|
||||
const hasNpm = await this.commandExists('npm');
|
||||
const hasPython = await this.commandExists('python3');
|
||||
|
||||
if (hasNpm) {
|
||||
// Install Node.js Gemini CLI wrapper
|
||||
await this.runCommand('npm', ['install', '-g', '@google/generative-ai-cli']);
|
||||
} else if (hasPython) {
|
||||
// Install Python SDK
|
||||
await this.runCommand('pip', ['install', '--user', 'google-generativeai']);
|
||||
|
||||
// Create a wrapper script
|
||||
const wrapperScript = `#!/usr/bin/env python3
|
||||
import sys
|
||||
import google.generativeai as genai
|
||||
import os
|
||||
|
||||
genai.configure(api_key=os.environ.get('GOOGLE_API_KEY'))
|
||||
model = genai.GenerativeModel('gemini-pro')
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
prompt = ' '.join(sys.argv[1:])
|
||||
response = model.generate_content(prompt)
|
||||
print(response.text)
|
||||
`;
|
||||
const wrapperPath = path.join(process.env.HOME || '', '.local', 'bin', 'gemini');
|
||||
fs.mkdirSync(path.dirname(wrapperPath), { recursive: true });
|
||||
fs.writeFileSync(wrapperPath, wrapperScript);
|
||||
fs.chmodSync(wrapperPath, '755');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to install Gemini CLI:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkInstallation(): Promise<boolean> {
|
||||
return this.commandExists('gemini') ||
|
||||
(await this.commandExists('python3') && await this.pythonPackageExists('google-generativeai'));
|
||||
}
|
||||
|
||||
generatePrompt(task: string, context?: any): string {
|
||||
const role = context?.role || 'You are an expert AI assistant specialized in coding and development.';
|
||||
const constraints = context?.constraints || [];
|
||||
|
||||
let prompt = `${role}\n\n`;
|
||||
|
||||
if (constraints.length > 0) {
|
||||
prompt += `Constraints:\n${constraints.map(c => `- ${c}`).join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
prompt += `Task: ${task}\n\nPlease provide a comprehensive response.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
protected getApiKey(): string {
|
||||
const config = vscode.workspace.getConfiguration('hanzo');
|
||||
const googleKey = config.get<string>('googleApiKey');
|
||||
if (googleKey) return googleKey;
|
||||
|
||||
return process.env.GOOGLE_API_KEY || '';
|
||||
}
|
||||
|
||||
private async commandExists(command: string): Promise<boolean> {
|
||||
try {
|
||||
execSync(`which ${command}`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async pythonPackageExists(packageName: string): Promise<boolean> {
|
||||
try {
|
||||
execSync(`python3 -c "import ${packageName.replace('-', '_')}"`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini-specific methods
|
||||
async analyzeCode(code: string, language: string): Promise<string> {
|
||||
const prompt = `Analyze this ${language} code and provide insights on:
|
||||
1. Code quality
|
||||
2. Performance characteristics
|
||||
3. Security considerations
|
||||
4. Best practices adherence
|
||||
5. Potential improvements
|
||||
|
||||
Code:
|
||||
${code}`;
|
||||
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async generateProject(description: string, stack: string[]): Promise<string> {
|
||||
const prompt = `Generate a complete project structure for:
|
||||
Description: ${description}
|
||||
Tech Stack: ${stack.join(', ')}
|
||||
|
||||
Include:
|
||||
- Directory structure
|
||||
- Key files with starter code
|
||||
- Configuration files
|
||||
- README with setup instructions`;
|
||||
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async refactorCode(code: string, language: string, goals: string[]): Promise<string> {
|
||||
const prompt = `Refactor this ${language} code to achieve:
|
||||
${goals.map((g, i) => `${i + 1}. ${g}`).join('\n')}
|
||||
|
||||
Original code:
|
||||
${code}
|
||||
|
||||
Provide the refactored code with explanations for changes.`;
|
||||
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async generateAPI(specification: string): Promise<string> {
|
||||
const prompt = `Generate a REST API based on this specification:
|
||||
${specification}
|
||||
|
||||
Include:
|
||||
- API endpoints
|
||||
- Request/response schemas
|
||||
- Authentication details
|
||||
- Example implementations
|
||||
- OpenAPI/Swagger documentation`;
|
||||
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async debugCode(code: string, error: string, language: string): Promise<string> {
|
||||
const prompt = `Debug this ${language} code that's producing an error:
|
||||
|
||||
Error:
|
||||
${error}
|
||||
|
||||
Code:
|
||||
${code}
|
||||
|
||||
Provide:
|
||||
1. Root cause analysis
|
||||
2. Fixed code
|
||||
3. Explanation of the fix
|
||||
4. Prevention tips`;
|
||||
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async compareApproaches(task: string, approaches: string[]): Promise<string> {
|
||||
const prompt = `Compare these approaches for: ${task}
|
||||
|
||||
Approaches:
|
||||
${approaches.map((a, i) => `${i + 1}. ${a}`).join('\n')}
|
||||
|
||||
Analyze:
|
||||
- Pros and cons of each
|
||||
- Performance implications
|
||||
- Maintainability
|
||||
- Best use cases
|
||||
- Recommendation with justification`;
|
||||
|
||||
const result = await this.execute(prompt);
|
||||
return result.output || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { BaseCLITool, CLIToolConfig } from '../common/base-cli';
|
||||
import * as vscode from 'vscode';
|
||||
import { execSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export class OpenHandsCLI extends BaseCLITool {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'OpenHands CLI',
|
||||
command: 'openhands',
|
||||
args: [],
|
||||
env: {
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || ''
|
||||
},
|
||||
model: 'gpt-4',
|
||||
apiKeyEnvVar: 'OPENAI_API_KEY',
|
||||
maxTokens: 8192,
|
||||
temperature: 0.7
|
||||
});
|
||||
}
|
||||
|
||||
async install(): Promise<boolean> {
|
||||
try {
|
||||
// Check if Docker is installed (required for OpenHands)
|
||||
const hasDocker = await this.commandExists('docker');
|
||||
if (!hasDocker) {
|
||||
throw new Error('Docker is required for OpenHands. Please install Docker first.');
|
||||
}
|
||||
|
||||
// Install via pip
|
||||
const hasPip = await this.commandExists('pip3');
|
||||
if (hasPip) {
|
||||
await this.runCommand('pip3', ['install', 'openhands']);
|
||||
} else {
|
||||
// Clone and install from source
|
||||
const installDir = path.join(process.env.HOME || '', '.hanzo-dev', 'tools');
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
|
||||
await this.runCommand('git', [
|
||||
'clone',
|
||||
'https://github.com/All-Hands-AI/OpenHands.git',
|
||||
path.join(installDir, 'openhands')
|
||||
]);
|
||||
|
||||
await this.runCommand('pip3', ['install', '-e', '.'], {
|
||||
cwd: path.join(installDir, 'openhands')
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to install OpenHands:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkInstallation(): Promise<boolean> {
|
||||
return this.commandExists('openhands') ||
|
||||
fs.existsSync(path.join(process.env.HOME || '', '.hanzo-dev', 'tools', 'openhands'));
|
||||
}
|
||||
|
||||
generatePrompt(task: string, context?: any): string {
|
||||
const workspace = context?.workspace || process.cwd();
|
||||
const model = context?.model || this.config.model;
|
||||
|
||||
// OpenHands uses a different prompt format
|
||||
return `--task "${task}" --workspace "${workspace}" --model ${model}`;
|
||||
}
|
||||
|
||||
protected getApiKey(): string {
|
||||
const config = vscode.workspace.getConfiguration('hanzo');
|
||||
|
||||
// OpenHands can use multiple API keys
|
||||
const openaiKey = config.get<string>('openaiApiKey') || process.env.OPENAI_API_KEY;
|
||||
const anthropicKey = config.get<string>('anthropicApiKey') || process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
// Return the appropriate key based on model
|
||||
if (this.config.model?.includes('claude')) {
|
||||
return anthropicKey || '';
|
||||
}
|
||||
return openaiKey || '';
|
||||
}
|
||||
|
||||
private async commandExists(command: string): Promise<boolean> {
|
||||
try {
|
||||
execSync(`which ${command}`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// OpenHands-specific methods
|
||||
async createAgent(task: string, workspace: string, config?: any): Promise<string> {
|
||||
const args = [
|
||||
'--task', task,
|
||||
'--workspace', workspace,
|
||||
'--model', config?.model || this.config.model
|
||||
];
|
||||
|
||||
if (config?.maxIterations) {
|
||||
args.push('--max-iterations', config.maxIterations);
|
||||
}
|
||||
|
||||
if (config?.agentClass) {
|
||||
args.push('--agent-class', config.agentClass);
|
||||
}
|
||||
|
||||
const result = await this.execute('', {
|
||||
env: {
|
||||
...process.env,
|
||||
OPENHANDS_ARGS: args.join(' ')
|
||||
}
|
||||
});
|
||||
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async runInDocker(task: string, workspace: string): Promise<string> {
|
||||
const dockerArgs = [
|
||||
'run', '-it', '--rm',
|
||||
'-v', `${workspace}:/workspace`,
|
||||
'-e', `OPENAI_API_KEY=${this.getApiKey()}`,
|
||||
'ghcr.io/all-hands-ai/openhands:latest',
|
||||
'--task', task
|
||||
];
|
||||
|
||||
const result = await this.execute('', {
|
||||
env: {
|
||||
...process.env,
|
||||
DOCKER_COMMAND: 'docker',
|
||||
DOCKER_ARGS: dockerArgs.join(' ')
|
||||
}
|
||||
});
|
||||
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async executeCodeAction(action: string, code: string, language: string): Promise<string> {
|
||||
const validActions = ['implement', 'debug', 'refactor', 'test', 'document'];
|
||||
if (!validActions.includes(action)) {
|
||||
throw new Error(`Invalid action: ${action}. Must be one of: ${validActions.join(', ')}`);
|
||||
}
|
||||
|
||||
const task = `${action} this ${language} code:\n\n${code}`;
|
||||
const result = await this.execute(task);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async generateTests(code: string, language: string, framework?: string): Promise<string> {
|
||||
const task = `Generate comprehensive unit tests for this ${language} code${framework ? ` using ${framework}` : ''}:\n\n${code}`;
|
||||
const result = await this.execute(task);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async reviewPullRequest(prUrl: string): Promise<string> {
|
||||
const task = `Review this pull request and provide feedback: ${prUrl}`;
|
||||
const result = await this.execute(task);
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async explainCodebase(directory: string, focus?: string): Promise<string> {
|
||||
const task = focus
|
||||
? `Explain the ${focus} functionality in the codebase at ${directory}`
|
||||
: `Provide an overview of the codebase architecture at ${directory}`;
|
||||
|
||||
const result = await this.execute(task, { cwd: directory });
|
||||
return result.output || '';
|
||||
}
|
||||
|
||||
async implementFeature(description: string, targetFile?: string): Promise<string> {
|
||||
const task = targetFile
|
||||
? `Implement this feature in ${targetFile}: ${description}`
|
||||
: `Implement this feature: ${description}`;
|
||||
|
||||
const result = await this.execute(task);
|
||||
return result.output || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { performance } from 'perf_hooks';
|
||||
|
||||
export interface AsyncToolConfig {
|
||||
idleTimeout: number; // Time in ms before auto-quit (default: 5 minutes)
|
||||
maxRuntime: number; // Maximum runtime in ms (default: 30 minutes)
|
||||
checkInterval: number; // How often to check status in ms (default: 1 second)
|
||||
outputBufferSize: number; // Max output to keep in memory (default: 10MB)
|
||||
persistResults: boolean; // Save results to disk (default: true)
|
||||
resultPath: string; // Where to save results
|
||||
}
|
||||
|
||||
export interface AsyncToolJob {
|
||||
id: string;
|
||||
toolName: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' | 'idle';
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
lastActivity?: Date;
|
||||
output: string[];
|
||||
error: string[];
|
||||
result?: any;
|
||||
metadata: Record<string, any>;
|
||||
process?: ChildProcess;
|
||||
idleTimer?: NodeJS.Timeout;
|
||||
runtimeTimer?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export interface AsyncToolResult {
|
||||
jobId: string;
|
||||
status: AsyncToolJob['status'];
|
||||
output: string;
|
||||
error: string;
|
||||
result?: any;
|
||||
duration?: number;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export class AsyncToolWrapper extends EventEmitter {
|
||||
private jobs: Map<string, AsyncToolJob> = new Map();
|
||||
private config: AsyncToolConfig;
|
||||
private checkTimer?: NodeJS.Timer;
|
||||
private outputStreams: Map<string, fs.WriteStream> = new Map();
|
||||
|
||||
constructor(config: Partial<AsyncToolConfig> = {}) {
|
||||
super();
|
||||
this.config = {
|
||||
idleTimeout: config.idleTimeout || 5 * 60 * 1000, // 5 minutes
|
||||
maxRuntime: config.maxRuntime || 30 * 60 * 1000, // 30 minutes
|
||||
checkInterval: config.checkInterval || 1000, // 1 second
|
||||
outputBufferSize: config.outputBufferSize || 10 * 1024 * 1024, // 10MB
|
||||
persistResults: config.persistResults ?? true,
|
||||
resultPath: config.resultPath || path.join(process.cwd(), '.hanzo-dev', 'async-results')
|
||||
};
|
||||
|
||||
if (this.config.persistResults) {
|
||||
fs.mkdirSync(this.config.resultPath, { recursive: true });
|
||||
}
|
||||
|
||||
this.startStatusChecker();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an async version of any tool/command
|
||||
*/
|
||||
async createAsyncTool(
|
||||
toolName: string,
|
||||
command: string,
|
||||
args: string[] = [],
|
||||
metadata: Record<string, any> = {}
|
||||
): Promise<string> {
|
||||
const jobId = uuidv4();
|
||||
|
||||
const job: AsyncToolJob = {
|
||||
id: jobId,
|
||||
toolName,
|
||||
command,
|
||||
args,
|
||||
status: 'queued',
|
||||
output: [],
|
||||
error: [],
|
||||
metadata
|
||||
};
|
||||
|
||||
this.jobs.set(jobId, job);
|
||||
this.emit('job:created', job);
|
||||
|
||||
// Start the job asynchronously
|
||||
setImmediate(() => this.startJob(job));
|
||||
|
||||
return jobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create async versions of MCP tools
|
||||
*/
|
||||
async createAsyncMCPTool(toolName: string, args: any, metadata: Record<string, any> = {}): Promise<string> {
|
||||
// Convert MCP tool call to async
|
||||
const mcpCommand = this.getMCPCommand(toolName);
|
||||
const mcpArgs = this.serializeMCPArgs(args);
|
||||
|
||||
return this.createAsyncTool(`mcp_${toolName}`, mcpCommand, mcpArgs, {
|
||||
...metadata,
|
||||
mcpTool: true,
|
||||
originalArgs: args
|
||||
});
|
||||
}
|
||||
|
||||
private getMCPCommand(toolName: string): string {
|
||||
// Map tool names to their executable commands
|
||||
const toolMap: Record<string, string> = {
|
||||
'search': 'mcp-search',
|
||||
'grep': 'mcp-grep',
|
||||
'read': 'mcp-read',
|
||||
'edit': 'mcp-edit',
|
||||
'bash': 'bash',
|
||||
'webfetch': 'mcp-webfetch',
|
||||
// Add more as needed
|
||||
};
|
||||
|
||||
return toolMap[toolName.toLowerCase()] || toolName;
|
||||
}
|
||||
|
||||
private serializeMCPArgs(args: any): string[] {
|
||||
// Convert MCP args to command line arguments
|
||||
const result: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
if (value !== null && value !== undefined) {
|
||||
result.push(`--${key}`);
|
||||
if (typeof value === 'boolean') {
|
||||
// Boolean flags don't need values
|
||||
} else if (Array.isArray(value)) {
|
||||
result.push(value.join(','));
|
||||
} else {
|
||||
result.push(String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async startJob(job: AsyncToolJob): Promise<void> {
|
||||
try {
|
||||
job.status = 'running';
|
||||
job.startTime = new Date();
|
||||
job.lastActivity = new Date();
|
||||
|
||||
// Create output stream if persisting
|
||||
if (this.config.persistResults) {
|
||||
const outputPath = path.join(this.config.resultPath, `${job.id}.log`);
|
||||
const stream = fs.createWriteStream(outputPath);
|
||||
this.outputStreams.set(job.id, stream);
|
||||
}
|
||||
|
||||
// Spawn the process
|
||||
job.process = spawn(job.command, job.args, {
|
||||
shell: true,
|
||||
env: {
|
||||
...process.env,
|
||||
HANZO_DEV_ASYNC: 'true',
|
||||
HANZO_DEV_JOB_ID: job.id
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stdout
|
||||
job.process.stdout?.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
this.handleOutput(job, output, 'stdout');
|
||||
});
|
||||
|
||||
// Handle stderr
|
||||
job.process.stderr?.on('data', (data) => {
|
||||
const error = data.toString();
|
||||
this.handleOutput(job, error, 'stderr');
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
job.process.on('exit', (code, signal) => {
|
||||
this.handleJobComplete(job, code, signal);
|
||||
});
|
||||
|
||||
// Handle process error
|
||||
job.process.on('error', (error) => {
|
||||
job.status = 'failed';
|
||||
job.error.push(error.message);
|
||||
this.handleJobComplete(job, 1);
|
||||
});
|
||||
|
||||
// Set up idle timeout
|
||||
this.resetIdleTimer(job);
|
||||
|
||||
// Set up max runtime timeout
|
||||
job.runtimeTimer = setTimeout(() => {
|
||||
if (job.status === 'running') {
|
||||
this.cancelJob(job.id, 'Max runtime exceeded');
|
||||
}
|
||||
}, this.config.maxRuntime);
|
||||
|
||||
this.emit('job:started', job);
|
||||
} catch (error) {
|
||||
job.status = 'failed';
|
||||
job.error.push(error.message);
|
||||
this.emit('job:failed', job);
|
||||
}
|
||||
}
|
||||
|
||||
private handleOutput(job: AsyncToolJob, data: string, type: 'stdout' | 'stderr'): void {
|
||||
job.lastActivity = new Date();
|
||||
|
||||
// Add to appropriate buffer
|
||||
const buffer = type === 'stdout' ? job.output : job.error;
|
||||
buffer.push(data);
|
||||
|
||||
// Trim buffer if too large
|
||||
const totalSize = buffer.reduce((sum, str) => sum + str.length, 0);
|
||||
if (totalSize > this.config.outputBufferSize) {
|
||||
// Remove oldest entries until under limit
|
||||
while (buffer.length > 0 &&
|
||||
buffer.reduce((sum, str) => sum + str.length, 0) > this.config.outputBufferSize) {
|
||||
buffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Write to file if persisting
|
||||
const stream = this.outputStreams.get(job.id);
|
||||
if (stream) {
|
||||
stream.write(`[${type}] ${new Date().toISOString()} ${data}`);
|
||||
}
|
||||
|
||||
// Reset idle timer
|
||||
this.resetIdleTimer(job);
|
||||
|
||||
// Emit output event
|
||||
this.emit('job:output', {
|
||||
jobId: job.id,
|
||||
type,
|
||||
data
|
||||
});
|
||||
|
||||
// Check for completion patterns
|
||||
this.checkForCompletion(job, data);
|
||||
}
|
||||
|
||||
private checkForCompletion(job: AsyncToolJob, output: string): void {
|
||||
// Look for common completion patterns
|
||||
const completionPatterns = [
|
||||
/task completed/i,
|
||||
/finished successfully/i,
|
||||
/done\./i,
|
||||
/completed in \d+/i,
|
||||
/\[done\]/i
|
||||
];
|
||||
|
||||
if (completionPatterns.some(pattern => pattern.test(output))) {
|
||||
// Mark as idle instead of immediately completing
|
||||
job.status = 'idle';
|
||||
this.emit('job:idle', job);
|
||||
}
|
||||
}
|
||||
|
||||
private resetIdleTimer(job: AsyncToolJob): void {
|
||||
// Clear existing timer
|
||||
if (job.idleTimer) {
|
||||
clearTimeout(job.idleTimer);
|
||||
}
|
||||
|
||||
// Set new timer
|
||||
job.idleTimer = setTimeout(() => {
|
||||
if (job.status === 'running' || job.status === 'idle') {
|
||||
this.handleIdleTimeout(job);
|
||||
}
|
||||
}, this.config.idleTimeout);
|
||||
}
|
||||
|
||||
private handleIdleTimeout(job: AsyncToolJob): void {
|
||||
if (job.status === 'idle') {
|
||||
// If already idle, complete the job
|
||||
this.completeJob(job.id);
|
||||
} else {
|
||||
// Mark as idle
|
||||
job.status = 'idle';
|
||||
this.emit('job:idle', job);
|
||||
|
||||
// Give it one more idle period before completing
|
||||
this.resetIdleTimer(job);
|
||||
}
|
||||
}
|
||||
|
||||
private handleJobComplete(job: AsyncToolJob, code?: number | null, signal?: string | null): void {
|
||||
job.endTime = new Date();
|
||||
job.status = code === 0 ? 'completed' : 'failed';
|
||||
|
||||
if (job.idleTimer) {
|
||||
clearTimeout(job.idleTimer);
|
||||
}
|
||||
|
||||
if (job.runtimeTimer) {
|
||||
clearTimeout(job.runtimeTimer);
|
||||
}
|
||||
|
||||
// Close output stream
|
||||
const stream = this.outputStreams.get(job.id);
|
||||
if (stream) {
|
||||
stream.end();
|
||||
this.outputStreams.delete(job.id);
|
||||
}
|
||||
|
||||
// Parse result if possible
|
||||
try {
|
||||
const output = job.output.join('');
|
||||
if (output.trim().startsWith('{') || output.trim().startsWith('[')) {
|
||||
job.result = JSON.parse(output);
|
||||
}
|
||||
} catch (error) {
|
||||
// Not JSON, keep as string
|
||||
}
|
||||
|
||||
// Save result if persisting
|
||||
if (this.config.persistResults) {
|
||||
this.saveJobResult(job);
|
||||
}
|
||||
|
||||
this.emit('job:completed', job);
|
||||
}
|
||||
|
||||
private saveJobResult(job: AsyncToolJob): void {
|
||||
const resultPath = path.join(this.config.resultPath, `${job.id}.json`);
|
||||
const result: AsyncToolResult = {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
output: job.output.join(''),
|
||||
error: job.error.join(''),
|
||||
result: job.result,
|
||||
duration: job.startTime && job.endTime ?
|
||||
job.endTime.getTime() - job.startTime.getTime() : undefined,
|
||||
metadata: job.metadata
|
||||
};
|
||||
|
||||
fs.writeFileSync(resultPath, JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the status of an async job
|
||||
*/
|
||||
async queryJob(jobId: string): Promise<AsyncToolResult | null> {
|
||||
const job = this.jobs.get(jobId);
|
||||
|
||||
if (!job) {
|
||||
// Try to load from disk
|
||||
if (this.config.persistResults) {
|
||||
const resultPath = path.join(this.config.resultPath, `${jobId}.json`);
|
||||
if (fs.existsSync(resultPath)) {
|
||||
return JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
output: job.output.join(''),
|
||||
error: job.error.join(''),
|
||||
result: job.result,
|
||||
duration: job.startTime ?
|
||||
(job.endTime ? job.endTime.getTime() : Date.now()) - job.startTime.getTime() : undefined,
|
||||
metadata: job.metadata
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a job to complete
|
||||
*/
|
||||
async waitForJob(jobId: string, timeout?: number): Promise<AsyncToolResult | null> {
|
||||
const startTime = Date.now();
|
||||
const maxWait = timeout || this.config.maxRuntime;
|
||||
|
||||
while (Date.now() - startTime < maxWait) {
|
||||
const result = await this.queryJob(jobId);
|
||||
|
||||
if (!result) {
|
||||
throw new Error(`Job ${jobId} not found`);
|
||||
}
|
||||
|
||||
if (['completed', 'failed', 'cancelled'].includes(result.status)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Wait before checking again
|
||||
await new Promise(resolve => setTimeout(resolve, this.config.checkInterval));
|
||||
}
|
||||
|
||||
throw new Error(`Timeout waiting for job ${jobId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a job alive (prevent idle timeout)
|
||||
*/
|
||||
keepAlive(jobId: string): void {
|
||||
const job = this.jobs.get(jobId);
|
||||
if (job && (job.status === 'running' || job.status === 'idle')) {
|
||||
job.lastActivity = new Date();
|
||||
this.resetIdleTimer(job);
|
||||
|
||||
if (job.status === 'idle') {
|
||||
job.status = 'running';
|
||||
this.emit('job:resumed', job);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send input to a running job
|
||||
*/
|
||||
sendInput(jobId: string, input: string): boolean {
|
||||
const job = this.jobs.get(jobId);
|
||||
|
||||
if (!job || !job.process || job.status !== 'running') {
|
||||
return false;
|
||||
}
|
||||
|
||||
job.process.stdin?.write(input + '\n');
|
||||
job.lastActivity = new Date();
|
||||
this.resetIdleTimer(job);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a job manually
|
||||
*/
|
||||
completeJob(jobId: string, result?: any): void {
|
||||
const job = this.jobs.get(jobId);
|
||||
|
||||
if (!job || ['completed', 'failed', 'cancelled'].includes(job.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
job.result = result;
|
||||
}
|
||||
|
||||
if (job.process && !job.process.killed) {
|
||||
job.process.kill('SIGTERM');
|
||||
} else {
|
||||
this.handleJobComplete(job, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running job
|
||||
*/
|
||||
cancelJob(jobId: string, reason?: string): void {
|
||||
const job = this.jobs.get(jobId);
|
||||
|
||||
if (!job || ['completed', 'failed', 'cancelled'].includes(job.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
job.status = 'cancelled';
|
||||
if (reason) {
|
||||
job.error.push(`Cancelled: ${reason}`);
|
||||
}
|
||||
|
||||
if (job.process && !job.process.killed) {
|
||||
job.process.kill('SIGKILL');
|
||||
}
|
||||
|
||||
this.handleJobComplete(job, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active jobs
|
||||
*/
|
||||
getActiveJobs(): AsyncToolJob[] {
|
||||
return Array.from(this.jobs.values()).filter(
|
||||
job => ['running', 'idle', 'queued'].includes(job.status)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all jobs
|
||||
*/
|
||||
getAllJobs(): AsyncToolJob[] {
|
||||
return Array.from(this.jobs.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up completed jobs
|
||||
*/
|
||||
cleanupCompletedJobs(olderThan?: number): number {
|
||||
const cutoff = Date.now() - (olderThan || 24 * 60 * 60 * 1000); // Default 24 hours
|
||||
let cleaned = 0;
|
||||
|
||||
for (const [jobId, job] of this.jobs) {
|
||||
if (['completed', 'failed', 'cancelled'].includes(job.status) &&
|
||||
job.endTime && job.endTime.getTime() < cutoff) {
|
||||
this.jobs.delete(jobId);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private startStatusChecker(): void {
|
||||
this.checkTimer = setInterval(() => {
|
||||
// Emit status for monitoring
|
||||
const stats = {
|
||||
total: this.jobs.size,
|
||||
running: 0,
|
||||
idle: 0,
|
||||
completed: 0,
|
||||
failed: 0
|
||||
};
|
||||
|
||||
for (const job of this.jobs.values()) {
|
||||
stats[job.status]++;
|
||||
}
|
||||
|
||||
this.emit('status', stats);
|
||||
}, 10000); // Every 10 seconds
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
// Cancel all running jobs
|
||||
for (const job of this.getActiveJobs()) {
|
||||
this.cancelJob(job.id, 'System shutdown');
|
||||
}
|
||||
|
||||
// Clear timers
|
||||
if (this.checkTimer) {
|
||||
clearInterval(this.checkTimer);
|
||||
}
|
||||
|
||||
// Close all streams
|
||||
for (const stream of this.outputStreams.values()) {
|
||||
stream.end();
|
||||
}
|
||||
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { EventEmitter } from 'events';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { CLIToolManager, CLIToolType } from '../cli-tool-manager';
|
||||
import { DevMonitor } from './dev-monitor';
|
||||
import { PlatformSyncService } from './sync-service';
|
||||
|
||||
export interface DevToolInstance {
|
||||
id: string;
|
||||
type: CLIToolType | 'openhands' | 'aider' | 'custom';
|
||||
sessionId: string;
|
||||
process?: ChildProcess;
|
||||
worktreePath?: string;
|
||||
branch?: string;
|
||||
status: 'initializing' | 'running' | 'stopped' | 'error';
|
||||
config: DevToolConfig;
|
||||
startTime: Date;
|
||||
lastActivity?: Date;
|
||||
}
|
||||
|
||||
export interface DevToolConfig {
|
||||
name: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
workingDirectory: string;
|
||||
useWorktree?: boolean;
|
||||
worktreePrefix?: string;
|
||||
autoSync?: boolean;
|
||||
sessionTimeout?: number;
|
||||
}
|
||||
|
||||
export interface LauncherConfig {
|
||||
maxInstances: number;
|
||||
defaultTimeout: number;
|
||||
gitRoot: string;
|
||||
workspacePath: string;
|
||||
enableSync: boolean;
|
||||
syncConfig?: any;
|
||||
}
|
||||
|
||||
export class DevLauncher extends EventEmitter {
|
||||
private instances: Map<string, DevToolInstance> = new Map();
|
||||
private cliManager: CLIToolManager;
|
||||
private monitor: DevMonitor;
|
||||
private syncService?: PlatformSyncService;
|
||||
private config: LauncherConfig;
|
||||
private outputChannel: vscode.OutputChannel;
|
||||
|
||||
constructor(config: LauncherConfig) {
|
||||
super();
|
||||
this.config = config;
|
||||
this.cliManager = new CLIToolManager();
|
||||
this.monitor = new DevMonitor({
|
||||
logPath: path.join(config.workspacePath, '.hanzo-dev', 'logs'),
|
||||
maxLogSize: 10 * 1024 * 1024, // 10MB
|
||||
rotateInterval: 24 * 60 * 60 * 1000, // Daily
|
||||
metricsInterval: 5000, // 5 seconds
|
||||
enablePerfMonitoring: true
|
||||
});
|
||||
|
||||
this.outputChannel = vscode.window.createOutputChannel('Hanzo Dev Launcher');
|
||||
|
||||
if (config.enableSync && config.syncConfig) {
|
||||
this.syncService = new PlatformSyncService(config.syncConfig);
|
||||
this.setupSyncHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.cliManager.initialize();
|
||||
|
||||
if (this.syncService) {
|
||||
await this.syncService.connect();
|
||||
}
|
||||
|
||||
this.outputChannel.appendLine('Hanzo Dev Launcher initialized');
|
||||
}
|
||||
|
||||
private setupSyncHandlers(): void {
|
||||
if (!this.syncService) return;
|
||||
|
||||
this.syncService.on('command_request', (request) => {
|
||||
this.handleRemoteCommand(request);
|
||||
});
|
||||
|
||||
this.syncService.on('file_synced', (data) => {
|
||||
this.outputChannel.appendLine(`File synced: ${data.filePath}`);
|
||||
});
|
||||
}
|
||||
|
||||
async launchTool(
|
||||
type: DevToolInstance['type'],
|
||||
task: string,
|
||||
options: Partial<DevToolConfig> = {}
|
||||
): Promise<string> {
|
||||
if (this.instances.size >= this.config.maxInstances) {
|
||||
throw new Error(`Maximum instances (${this.config.maxInstances}) reached`);
|
||||
}
|
||||
|
||||
const instanceId = `${type}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const sessionId = this.monitor.startSession(type, options.workingDirectory || this.config.workspacePath, options.name || 'main');
|
||||
|
||||
const config: DevToolConfig = {
|
||||
name: options.name || `${type}-session`,
|
||||
command: this.getToolCommand(type),
|
||||
args: options.args || [],
|
||||
env: options.env || {},
|
||||
workingDirectory: options.workingDirectory || this.config.workspacePath,
|
||||
useWorktree: options.useWorktree ?? true,
|
||||
worktreePrefix: options.worktreePrefix || `ai/${type}`,
|
||||
autoSync: options.autoSync ?? true,
|
||||
sessionTimeout: options.sessionTimeout || this.config.defaultTimeout
|
||||
};
|
||||
|
||||
const instance: DevToolInstance = {
|
||||
id: instanceId,
|
||||
type,
|
||||
sessionId,
|
||||
status: 'initializing',
|
||||
config,
|
||||
startTime: new Date()
|
||||
};
|
||||
|
||||
this.instances.set(instanceId, instance);
|
||||
|
||||
try {
|
||||
// Create worktree if requested
|
||||
if (config.useWorktree && this.isGitRepo()) {
|
||||
await this.createWorktree(instance);
|
||||
}
|
||||
|
||||
// Launch the tool
|
||||
await this.startToolProcess(instance, task);
|
||||
|
||||
// Register with sync service
|
||||
if (this.syncService && config.autoSync) {
|
||||
this.syncService.registerDevTool(instanceId, {
|
||||
sessionId,
|
||||
workingDirectory: instance.worktreePath || config.workingDirectory,
|
||||
branch: instance.branch || 'main',
|
||||
files: [],
|
||||
lastActivity: new Date(),
|
||||
metadata: { type, task }
|
||||
});
|
||||
}
|
||||
|
||||
this.emit('tool:launched', instance);
|
||||
this.outputChannel.appendLine(`Launched ${type} instance: ${instanceId}`);
|
||||
|
||||
return instanceId;
|
||||
} catch (error) {
|
||||
instance.status = 'error';
|
||||
this.monitor.endSession(sessionId, 'failed');
|
||||
this.monitor.log('error', 'launcher', `Failed to launch ${type}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private getToolCommand(type: DevToolInstance['type']): string {
|
||||
switch (type) {
|
||||
case 'claude':
|
||||
return 'claude';
|
||||
case 'codex':
|
||||
return 'openai';
|
||||
case 'gemini':
|
||||
return 'gemini';
|
||||
case 'openhands':
|
||||
return 'openhands';
|
||||
case 'aider':
|
||||
return 'aider';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
private isGitRepo(): boolean {
|
||||
try {
|
||||
execSync('git rev-parse --git-dir', {
|
||||
cwd: this.config.gitRoot,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async createWorktree(instance: DevToolInstance): Promise<void> {
|
||||
const { worktreePrefix } = instance.config;
|
||||
const branchName = `${worktreePrefix}/${instance.id}`;
|
||||
const worktreePath = path.join(this.config.gitRoot, '.worktrees', instance.id);
|
||||
|
||||
try {
|
||||
// Create worktree with new branch
|
||||
execSync(`git worktree add -b "${branchName}" "${worktreePath}"`, {
|
||||
cwd: this.config.gitRoot,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
instance.branch = branchName;
|
||||
instance.worktreePath = worktreePath;
|
||||
instance.config.workingDirectory = worktreePath;
|
||||
|
||||
this.outputChannel.appendLine(`Created worktree: ${worktreePath} (branch: ${branchName})`);
|
||||
this.monitor.log('info', 'launcher', `Created worktree for ${instance.type}`);
|
||||
} catch (error) {
|
||||
this.monitor.log('error', 'launcher', `Failed to create worktree: ${error.message}`);
|
||||
throw new Error(`Failed to create worktree: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async startToolProcess(instance: DevToolInstance, task: string): Promise<void> {
|
||||
const { command, args = [], env = {}, workingDirectory } = instance.config;
|
||||
|
||||
// For known CLI tools, use the CLIToolManager
|
||||
if (['claude', 'codex', 'gemini'].includes(instance.type)) {
|
||||
const result = await this.cliManager.executeTool(
|
||||
instance.type as CLIToolType,
|
||||
task,
|
||||
{ cwd: workingDirectory }
|
||||
);
|
||||
|
||||
instance.status = result.success ? 'running' : 'error';
|
||||
this.monitor.recordCommand(instance.sessionId, command, [task]);
|
||||
this.monitor.recordCommandComplete(instance.sessionId, result.success);
|
||||
|
||||
if (result.output) {
|
||||
this.monitor.log('info', instance.type, result.output);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For other tools, spawn directly
|
||||
const processEnv = {
|
||||
...process.env,
|
||||
...env,
|
||||
HANZO_DEV_SESSION: instance.sessionId,
|
||||
HANZO_DEV_INSTANCE: instance.id
|
||||
};
|
||||
|
||||
// Special handling for specific tools
|
||||
let fullCommand = command;
|
||||
let fullArgs = [...args];
|
||||
|
||||
switch (instance.type) {
|
||||
case 'aider':
|
||||
// Aider-specific setup
|
||||
fullArgs = ['--yes', '--auto-commits', ...args, task];
|
||||
break;
|
||||
case 'openhands':
|
||||
// OpenHands-specific setup
|
||||
fullArgs = ['--workspace', workingDirectory, '--task', task, ...args];
|
||||
break;
|
||||
}
|
||||
|
||||
instance.process = spawn(fullCommand, fullArgs, {
|
||||
cwd: workingDirectory,
|
||||
env: processEnv,
|
||||
shell: true
|
||||
});
|
||||
|
||||
instance.status = 'running';
|
||||
instance.lastActivity = new Date();
|
||||
|
||||
// Handle process output
|
||||
instance.process.stdout?.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
this.monitor.log('info', instance.type, output);
|
||||
|
||||
if (this.syncService) {
|
||||
this.syncService.logOutput(instance.id, output);
|
||||
}
|
||||
|
||||
instance.lastActivity = new Date();
|
||||
this.emit('tool:output', { instanceId: instance.id, output });
|
||||
});
|
||||
|
||||
instance.process.stderr?.on('data', (data) => {
|
||||
const error = data.toString();
|
||||
this.monitor.log('error', instance.type, error);
|
||||
|
||||
if (this.syncService) {
|
||||
this.syncService.logError(instance.id, error);
|
||||
}
|
||||
|
||||
this.emit('tool:error', { instanceId: instance.id, error });
|
||||
});
|
||||
|
||||
instance.process.on('exit', (code) => {
|
||||
instance.status = 'stopped';
|
||||
this.monitor.endSession(instance.sessionId, code === 0 ? 'completed' : 'failed');
|
||||
|
||||
if (this.syncService) {
|
||||
this.syncService.updateStatus(instance.id, 'stopped', { exitCode: code });
|
||||
}
|
||||
|
||||
this.emit('tool:stopped', { instanceId: instance.id, exitCode: code });
|
||||
|
||||
// Clean up worktree after a delay
|
||||
if (instance.worktreePath) {
|
||||
setTimeout(() => this.cleanupWorktree(instance), 60000);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up session timeout
|
||||
if (instance.config.sessionTimeout) {
|
||||
setTimeout(() => {
|
||||
if (instance.status === 'running') {
|
||||
this.stopTool(instance.id);
|
||||
}
|
||||
}, instance.config.sessionTimeout);
|
||||
}
|
||||
|
||||
this.monitor.recordCommand(instance.sessionId, fullCommand, fullArgs);
|
||||
}
|
||||
|
||||
async stopTool(instanceId: string): Promise<void> {
|
||||
const instance = this.instances.get(instanceId);
|
||||
if (!instance) return;
|
||||
|
||||
if (instance.process) {
|
||||
instance.process.kill('SIGTERM');
|
||||
|
||||
// Force kill after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (instance.process && !instance.process.killed) {
|
||||
instance.process.kill('SIGKILL');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
instance.status = 'stopped';
|
||||
this.monitor.endSession(instance.sessionId);
|
||||
|
||||
if (this.syncService) {
|
||||
this.syncService.updateStatus(instanceId, 'stopped');
|
||||
}
|
||||
|
||||
this.emit('tool:stopped', { instanceId });
|
||||
}
|
||||
|
||||
async stopAllTools(): Promise<void> {
|
||||
const promises = Array.from(this.instances.keys()).map(
|
||||
id => this.stopTool(id)
|
||||
);
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
private async cleanupWorktree(instance: DevToolInstance): Promise<void> {
|
||||
if (!instance.worktreePath || !instance.branch) return;
|
||||
|
||||
try {
|
||||
// Remove worktree
|
||||
execSync(`git worktree remove --force "${instance.worktreePath}"`, {
|
||||
cwd: this.config.gitRoot,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
// Delete branch
|
||||
execSync(`git branch -D "${instance.branch}"`, {
|
||||
cwd: this.config.gitRoot,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
this.outputChannel.appendLine(`Cleaned up worktree: ${instance.worktreePath}`);
|
||||
} catch (error) {
|
||||
this.monitor.log('warn', 'launcher', `Failed to cleanup worktree: ${error.message}`);
|
||||
}
|
||||
|
||||
// Remove instance from map
|
||||
this.instances.delete(instance.id);
|
||||
}
|
||||
|
||||
private async handleRemoteCommand(request: any): Promise<void> {
|
||||
const { toolId, command, args } = request.data;
|
||||
const instance = this.instances.get(toolId);
|
||||
|
||||
if (!instance || !instance.process) {
|
||||
this.syncService?.logError(toolId, 'Instance not found or not running');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send command to process stdin
|
||||
if (instance.process.stdin) {
|
||||
instance.process.stdin.write(`${command} ${args.join(' ')}\n`);
|
||||
this.monitor.recordCommand(instance.sessionId, command, args);
|
||||
}
|
||||
}
|
||||
|
||||
async launchParallel(
|
||||
tasks: Array<{ type: DevToolInstance['type']; task: string; options?: Partial<DevToolConfig> }>
|
||||
): Promise<string[]> {
|
||||
const results = await Promise.allSettled(
|
||||
tasks.map(t => this.launchTool(t.type, t.task, t.options))
|
||||
);
|
||||
|
||||
const instanceIds: string[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
instanceIds.push(result.value);
|
||||
} else {
|
||||
this.monitor.log('error', 'launcher', `Failed to launch tool: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
return instanceIds;
|
||||
}
|
||||
|
||||
getInstance(instanceId: string): DevToolInstance | undefined {
|
||||
return this.instances.get(instanceId);
|
||||
}
|
||||
|
||||
getActiveInstances(): DevToolInstance[] {
|
||||
return Array.from(this.instances.values()).filter(
|
||||
i => i.status === 'running'
|
||||
);
|
||||
}
|
||||
|
||||
getAllInstances(): DevToolInstance[] {
|
||||
return Array.from(this.instances.values());
|
||||
}
|
||||
|
||||
getInstancesByType(type: DevToolInstance['type']): DevToolInstance[] {
|
||||
return Array.from(this.instances.values()).filter(
|
||||
i => i.type === type
|
||||
);
|
||||
}
|
||||
|
||||
showLauncherView(): void {
|
||||
this.outputChannel.show();
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await this.stopAllTools();
|
||||
|
||||
if (this.syncService) {
|
||||
await this.syncService.disconnect();
|
||||
}
|
||||
|
||||
this.monitor.dispose();
|
||||
this.cliManager.dispose();
|
||||
this.outputChannel.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { performance } from 'perf_hooks';
|
||||
|
||||
export interface DevToolSession {
|
||||
id: string;
|
||||
toolType: string;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
status: 'running' | 'completed' | 'failed' | 'paused';
|
||||
workingDirectory: string;
|
||||
branch: string;
|
||||
metrics: SessionMetrics;
|
||||
logs: LogEntry[];
|
||||
}
|
||||
|
||||
export interface SessionMetrics {
|
||||
commandsExecuted: number;
|
||||
filesModified: number;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
tokensUsed: number;
|
||||
apiCalls: number;
|
||||
errors: number;
|
||||
cpuUsage?: number;
|
||||
memoryUsage?: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: Date;
|
||||
level: 'info' | 'warn' | 'error' | 'debug';
|
||||
source: string;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface MonitorConfig {
|
||||
logPath: string;
|
||||
maxLogSize: number;
|
||||
rotateInterval: number;
|
||||
metricsInterval: number;
|
||||
enablePerfMonitoring: boolean;
|
||||
}
|
||||
|
||||
export class DevMonitor extends EventEmitter {
|
||||
private sessions: Map<string, DevToolSession> = new Map();
|
||||
private config: MonitorConfig;
|
||||
private logStream?: fs.WriteStream;
|
||||
private metricsTimer?: NodeJS.Timer;
|
||||
private outputChannel: vscode.OutputChannel;
|
||||
private statusBar: vscode.StatusBarItem;
|
||||
private perfObserver?: PerformanceObserver;
|
||||
|
||||
constructor(config: MonitorConfig) {
|
||||
super();
|
||||
this.config = config;
|
||||
this.outputChannel = vscode.window.createOutputChannel('Hanzo Dev Monitor');
|
||||
this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
|
||||
this.statusBar.command = 'hanzo-dev.showMonitor';
|
||||
|
||||
this.initializeLogging();
|
||||
this.startMetricsCollection();
|
||||
}
|
||||
|
||||
private initializeLogging(): void {
|
||||
// Create log directory if it doesn't exist
|
||||
fs.mkdirSync(this.config.logPath, { recursive: true });
|
||||
|
||||
// Create log file with rotation
|
||||
const logFile = path.join(this.config.logPath, `hanzo-dev-${Date.now()}.log`);
|
||||
this.logStream = fs.createWriteStream(logFile, { flags: 'a' });
|
||||
|
||||
// Set up log rotation
|
||||
setInterval(() => this.rotateLog(), this.config.rotateInterval);
|
||||
}
|
||||
|
||||
private rotateLog(): void {
|
||||
if (!this.logStream) return;
|
||||
|
||||
const stats = fs.statSync(this.logStream.path.toString());
|
||||
if (stats.size > this.config.maxLogSize) {
|
||||
this.logStream.end();
|
||||
const newLogFile = path.join(this.config.logPath, `hanzo-dev-${Date.now()}.log`);
|
||||
this.logStream = fs.createWriteStream(newLogFile, { flags: 'a' });
|
||||
}
|
||||
}
|
||||
|
||||
private startMetricsCollection(): void {
|
||||
this.metricsTimer = setInterval(() => {
|
||||
this.collectMetrics();
|
||||
this.updateStatusBar();
|
||||
}, this.config.metricsInterval);
|
||||
|
||||
if (this.config.enablePerfMonitoring) {
|
||||
this.setupPerformanceMonitoring();
|
||||
}
|
||||
}
|
||||
|
||||
private setupPerformanceMonitoring(): void {
|
||||
try {
|
||||
const { PerformanceObserver } = require('perf_hooks');
|
||||
this.perfObserver = new PerformanceObserver((items) => {
|
||||
for (const entry of items.getEntries()) {
|
||||
this.log('debug', 'performance', `${entry.name}: ${entry.duration}ms`);
|
||||
}
|
||||
});
|
||||
this.perfObserver.observe({ entryTypes: ['measure'] });
|
||||
} catch (error) {
|
||||
this.log('warn', 'monitor', 'Performance monitoring not available');
|
||||
}
|
||||
}
|
||||
|
||||
startSession(toolType: string, workingDirectory: string, branch: string): string {
|
||||
const sessionId = `${toolType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const session: DevToolSession = {
|
||||
id: sessionId,
|
||||
toolType,
|
||||
startTime: new Date(),
|
||||
status: 'running',
|
||||
workingDirectory,
|
||||
branch,
|
||||
metrics: {
|
||||
commandsExecuted: 0,
|
||||
filesModified: 0,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
tokensUsed: 0,
|
||||
apiCalls: 0,
|
||||
errors: 0
|
||||
},
|
||||
logs: []
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
this.emit('session:started', session);
|
||||
this.log('info', 'monitor', `Started ${toolType} session: ${sessionId}`);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
endSession(sessionId: string, status: 'completed' | 'failed' = 'completed'): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session.endTime = new Date();
|
||||
session.status = status;
|
||||
|
||||
this.emit('session:ended', session);
|
||||
this.log('info', 'monitor', `Ended session ${sessionId}: ${status}`);
|
||||
|
||||
// Archive session after a delay
|
||||
setTimeout(() => this.archiveSession(sessionId), 60000);
|
||||
}
|
||||
|
||||
private archiveSession(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
// Save session data to file
|
||||
const archivePath = path.join(this.config.logPath, 'sessions', `${sessionId}.json`);
|
||||
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
|
||||
fs.writeFileSync(archivePath, JSON.stringify(session, null, 2));
|
||||
|
||||
// Remove from active sessions
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
log(level: LogEntry['level'], source: string, message: string, data?: any): void {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date(),
|
||||
level,
|
||||
source,
|
||||
message,
|
||||
data
|
||||
};
|
||||
|
||||
// Add to all active sessions
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.status === 'running') {
|
||||
session.logs.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Write to log file
|
||||
if (this.logStream) {
|
||||
this.logStream.write(JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
// Show in output channel
|
||||
const logMessage = `[${entry.timestamp.toISOString()}] [${entry.level.toUpperCase()}] [${entry.source}] ${entry.message}`;
|
||||
this.outputChannel.appendLine(logMessage);
|
||||
|
||||
// Show errors in UI
|
||||
if (level === 'error') {
|
||||
vscode.window.showErrorMessage(`Hanzo Dev: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
incrementMetric(sessionId: string, metric: keyof SessionMetrics, value: number = 1): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
(session.metrics[metric] as number) += value;
|
||||
this.emit('metrics:updated', { sessionId, metric, value });
|
||||
}
|
||||
|
||||
recordCommand(sessionId: string, command: string, args: string[]): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
this.incrementMetric(sessionId, 'commandsExecuted');
|
||||
this.log('info', session.toolType, `Command: ${command} ${args.join(' ')}`);
|
||||
|
||||
performance.mark(`cmd-start-${sessionId}`);
|
||||
}
|
||||
|
||||
recordCommandComplete(sessionId: string, success: boolean): void {
|
||||
performance.mark(`cmd-end-${sessionId}`);
|
||||
|
||||
try {
|
||||
performance.measure(
|
||||
`command-duration-${sessionId}`,
|
||||
`cmd-start-${sessionId}`,
|
||||
`cmd-end-${sessionId}`
|
||||
);
|
||||
} catch (error) {
|
||||
// Ignore if marks don't exist
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
this.incrementMetric(sessionId, 'errors');
|
||||
}
|
||||
}
|
||||
|
||||
recordFileChange(sessionId: string, filePath: string, linesAdded: number, linesRemoved: number): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
this.incrementMetric(sessionId, 'filesModified');
|
||||
this.incrementMetric(sessionId, 'linesAdded', linesAdded);
|
||||
this.incrementMetric(sessionId, 'linesRemoved', linesRemoved);
|
||||
|
||||
this.log('debug', session.toolType, `File modified: ${filePath} (+${linesAdded}/-${linesRemoved})`);
|
||||
}
|
||||
|
||||
recordAPICall(sessionId: string, endpoint: string, tokensUsed: number): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
this.incrementMetric(sessionId, 'apiCalls');
|
||||
this.incrementMetric(sessionId, 'tokensUsed', tokensUsed);
|
||||
|
||||
this.log('debug', session.toolType, `API call: ${endpoint} (${tokensUsed} tokens)`);
|
||||
}
|
||||
|
||||
private collectMetrics(): void {
|
||||
if (process.platform === 'darwin' || process.platform === 'linux') {
|
||||
// Collect system metrics for active sessions
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.status === 'running') {
|
||||
// Basic process metrics (would need proper implementation)
|
||||
session.metrics.cpuUsage = process.cpuUsage().user / 1000000;
|
||||
session.metrics.memoryUsage = process.memoryUsage().heapUsed / 1024 / 1024;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateStatusBar(): void {
|
||||
const activeSessions = Array.from(this.sessions.values()).filter(
|
||||
s => s.status === 'running'
|
||||
);
|
||||
|
||||
if (activeSessions.length === 0) {
|
||||
this.statusBar.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const totalCommands = activeSessions.reduce(
|
||||
(sum, s) => sum + s.metrics.commandsExecuted, 0
|
||||
);
|
||||
const totalTokens = activeSessions.reduce(
|
||||
(sum, s) => sum + s.metrics.tokensUsed, 0
|
||||
);
|
||||
|
||||
this.statusBar.text = `$(terminal) Hanzo Dev: ${activeSessions.length} active | ${totalCommands} cmds | ${totalTokens} tokens`;
|
||||
this.statusBar.tooltip = activeSessions.map(
|
||||
s => `${s.toolType}: ${s.metrics.commandsExecuted} commands`
|
||||
).join('\n');
|
||||
this.statusBar.show();
|
||||
}
|
||||
|
||||
getSession(sessionId: string): DevToolSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
getActiveSessions(): DevToolSession[] {
|
||||
return Array.from(this.sessions.values()).filter(
|
||||
s => s.status === 'running'
|
||||
);
|
||||
}
|
||||
|
||||
getAllSessions(): DevToolSession[] {
|
||||
return Array.from(this.sessions.values());
|
||||
}
|
||||
|
||||
pauseSession(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session && session.status === 'running') {
|
||||
session.status = 'paused';
|
||||
this.emit('session:paused', session);
|
||||
}
|
||||
}
|
||||
|
||||
resumeSession(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session && session.status === 'paused') {
|
||||
session.status = 'running';
|
||||
this.emit('session:resumed', session);
|
||||
}
|
||||
}
|
||||
|
||||
showMonitorView(): void {
|
||||
this.outputChannel.show();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.metricsTimer) {
|
||||
clearInterval(this.metricsTimer);
|
||||
}
|
||||
|
||||
if (this.perfObserver) {
|
||||
this.perfObserver.disconnect();
|
||||
}
|
||||
|
||||
if (this.logStream) {
|
||||
this.logStream.end();
|
||||
}
|
||||
|
||||
this.statusBar.dispose();
|
||||
this.outputChannel.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export interface SyncConfig {
|
||||
platformUrl: string;
|
||||
apiKey: string;
|
||||
projectId: string;
|
||||
syncInterval: number;
|
||||
enableBidirectional: boolean;
|
||||
}
|
||||
|
||||
export interface DevToolContext {
|
||||
toolId: string;
|
||||
sessionId: string;
|
||||
workingDirectory: string;
|
||||
branch: string;
|
||||
files: string[];
|
||||
lastActivity: Date;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SyncEvent {
|
||||
type: 'file_change' | 'command' | 'output' | 'error' | 'status';
|
||||
timestamp: Date;
|
||||
toolId: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export class PlatformSyncService extends EventEmitter {
|
||||
private config: SyncConfig;
|
||||
private ws?: WebSocket;
|
||||
private contexts: Map<string, DevToolContext> = new Map();
|
||||
private syncQueue: SyncEvent[] = [];
|
||||
private connected: boolean = false;
|
||||
private syncTimer?: NodeJS.Timer;
|
||||
private fileWatchers: Map<string, vscode.FileSystemWatcher> = new Map();
|
||||
|
||||
constructor(config: SyncConfig) {
|
||||
super();
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsUrl = `${this.config.platformUrl.replace('http', 'ws')}/sync`;
|
||||
this.ws = new WebSocket(wsUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.config.apiKey}`,
|
||||
'X-Project-ID': this.config.projectId
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('open', () => {
|
||||
this.connected = true;
|
||||
this.emit('connected');
|
||||
this.startSync();
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
this.handlePlatformMessage(JSON.parse(data.toString()));
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
this.emit('error', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
this.connected = false;
|
||||
this.emit('disconnected');
|
||||
this.stopSync();
|
||||
// Auto-reconnect after 5 seconds
|
||||
setTimeout(() => this.connect(), 5000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
registerDevTool(toolId: string, context: Omit<DevToolContext, 'toolId'>): void {
|
||||
const fullContext: DevToolContext = {
|
||||
toolId,
|
||||
...context
|
||||
};
|
||||
|
||||
this.contexts.set(toolId, fullContext);
|
||||
|
||||
// Set up file watching for bi-directional sync
|
||||
if (this.config.enableBidirectional) {
|
||||
this.setupFileWatching(toolId, context.workingDirectory);
|
||||
}
|
||||
|
||||
// Send registration event
|
||||
this.queueEvent({
|
||||
type: 'status',
|
||||
timestamp: new Date(),
|
||||
toolId,
|
||||
data: { status: 'registered', context: fullContext }
|
||||
});
|
||||
}
|
||||
|
||||
private setupFileWatching(toolId: string, directory: string): void {
|
||||
const pattern = new vscode.RelativePattern(directory, '**/*');
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
|
||||
|
||||
watcher.onDidChange((uri) => {
|
||||
this.handleFileChange(toolId, uri, 'changed');
|
||||
});
|
||||
|
||||
watcher.onDidCreate((uri) => {
|
||||
this.handleFileChange(toolId, uri, 'created');
|
||||
});
|
||||
|
||||
watcher.onDidDelete((uri) => {
|
||||
this.handleFileChange(toolId, uri, 'deleted');
|
||||
});
|
||||
|
||||
this.fileWatchers.set(toolId, watcher);
|
||||
}
|
||||
|
||||
private handleFileChange(toolId: string, uri: vscode.Uri, changeType: string): void {
|
||||
const context = this.contexts.get(toolId);
|
||||
if (!context) return;
|
||||
|
||||
const relativePath = path.relative(context.workingDirectory, uri.fsPath);
|
||||
|
||||
// Don't sync hidden files or node_modules
|
||||
if (relativePath.includes('node_modules') || relativePath.startsWith('.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.queueEvent({
|
||||
type: 'file_change',
|
||||
timestamp: new Date(),
|
||||
toolId,
|
||||
data: {
|
||||
path: relativePath,
|
||||
changeType,
|
||||
content: changeType !== 'deleted' ? fs.readFileSync(uri.fsPath, 'utf-8') : null,
|
||||
hash: changeType !== 'deleted' ? this.hashFile(uri.fsPath) : null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private hashFile(filePath: string): string {
|
||||
const content = fs.readFileSync(filePath);
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
logCommand(toolId: string, command: string, args: string[]): void {
|
||||
this.queueEvent({
|
||||
type: 'command',
|
||||
timestamp: new Date(),
|
||||
toolId,
|
||||
data: { command, args }
|
||||
});
|
||||
}
|
||||
|
||||
logOutput(toolId: string, output: string): void {
|
||||
this.queueEvent({
|
||||
type: 'output',
|
||||
timestamp: new Date(),
|
||||
toolId,
|
||||
data: { output }
|
||||
});
|
||||
}
|
||||
|
||||
logError(toolId: string, error: string): void {
|
||||
this.queueEvent({
|
||||
type: 'error',
|
||||
timestamp: new Date(),
|
||||
toolId,
|
||||
data: { error }
|
||||
});
|
||||
}
|
||||
|
||||
updateStatus(toolId: string, status: string, metadata?: any): void {
|
||||
const context = this.contexts.get(toolId);
|
||||
if (context) {
|
||||
context.lastActivity = new Date();
|
||||
if (metadata) {
|
||||
context.metadata = { ...context.metadata, ...metadata };
|
||||
}
|
||||
}
|
||||
|
||||
this.queueEvent({
|
||||
type: 'status',
|
||||
timestamp: new Date(),
|
||||
toolId,
|
||||
data: { status, metadata }
|
||||
});
|
||||
}
|
||||
|
||||
private queueEvent(event: SyncEvent): void {
|
||||
this.syncQueue.push(event);
|
||||
|
||||
// If connected, sync immediately
|
||||
if (this.connected) {
|
||||
this.syncEvents();
|
||||
}
|
||||
}
|
||||
|
||||
private startSync(): void {
|
||||
// Sync queued events immediately
|
||||
this.syncEvents();
|
||||
|
||||
// Set up periodic sync
|
||||
this.syncTimer = setInterval(() => {
|
||||
this.syncEvents();
|
||||
this.syncContexts();
|
||||
}, this.config.syncInterval);
|
||||
}
|
||||
|
||||
private stopSync(): void {
|
||||
if (this.syncTimer) {
|
||||
clearInterval(this.syncTimer);
|
||||
this.syncTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private syncEvents(): void {
|
||||
if (!this.connected || !this.ws || this.syncQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const events = [...this.syncQueue];
|
||||
this.syncQueue = [];
|
||||
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'sync_events',
|
||||
events
|
||||
}));
|
||||
}
|
||||
|
||||
private syncContexts(): void {
|
||||
if (!this.connected || !this.ws) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contexts = Array.from(this.contexts.values());
|
||||
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'sync_contexts',
|
||||
contexts
|
||||
}));
|
||||
}
|
||||
|
||||
private handlePlatformMessage(message: any): void {
|
||||
switch (message.type) {
|
||||
case 'file_update':
|
||||
this.handleRemoteFileUpdate(message);
|
||||
break;
|
||||
case 'command_request':
|
||||
this.emit('command_request', message);
|
||||
break;
|
||||
case 'context_update':
|
||||
this.handleContextUpdate(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleRemoteFileUpdate(message: any): void {
|
||||
if (!this.config.enableBidirectional) return;
|
||||
|
||||
const { toolId, filePath, content } = message.data;
|
||||
const context = this.contexts.get(toolId);
|
||||
|
||||
if (!context) return;
|
||||
|
||||
const fullPath = path.join(context.workingDirectory, filePath);
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
|
||||
// Write the file
|
||||
fs.writeFileSync(fullPath, content);
|
||||
|
||||
this.emit('file_synced', { toolId, filePath, fullPath });
|
||||
}
|
||||
|
||||
private handleContextUpdate(message: any): void {
|
||||
const { toolId, updates } = message.data;
|
||||
const context = this.contexts.get(toolId);
|
||||
|
||||
if (!context) return;
|
||||
|
||||
Object.assign(context, updates);
|
||||
this.emit('context_updated', { toolId, updates });
|
||||
}
|
||||
|
||||
getContext(toolId: string): DevToolContext | undefined {
|
||||
return this.contexts.get(toolId);
|
||||
}
|
||||
|
||||
getAllContexts(): DevToolContext[] {
|
||||
return Array.from(this.contexts.values());
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.stopSync();
|
||||
|
||||
// Clean up file watchers
|
||||
for (const watcher of this.fileWatchers.values()) {
|
||||
watcher.dispose();
|
||||
}
|
||||
this.fileWatchers.clear();
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = undefined;
|
||||
}
|
||||
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
+594
@@ -0,0 +1,594 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from 'commander';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import inquirer from 'inquirer';
|
||||
import { DevLauncher } from '../cli-tools/platform/dev-launcher';
|
||||
import { CLIToolManager } from '../cli-tools/cli-tool-manager';
|
||||
import { AsyncToolWrapper } from '../cli-tools/platform/async-tool-wrapper';
|
||||
import { HanzoAuth } from '../cli-tools/auth/hanzo-auth';
|
||||
|
||||
const program = new Command();
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf-8'));
|
||||
|
||||
program
|
||||
.name('dev')
|
||||
.description('Dev - Meta AI development tool that manages and runs all LLMs and CLI tools')
|
||||
.version(packageJson.version);
|
||||
|
||||
// Global auth instance
|
||||
const auth = new HanzoAuth();
|
||||
|
||||
// Login command
|
||||
program
|
||||
.command('login')
|
||||
.description('Login to Hanzo AI platform')
|
||||
.action(async () => {
|
||||
const spinner = ora('Logging in to Hanzo AI...').start();
|
||||
|
||||
try {
|
||||
const success = await auth.login();
|
||||
|
||||
if (success) {
|
||||
spinner.succeed('Successfully logged in to Hanzo AI!');
|
||||
|
||||
const creds = auth.getCredentials();
|
||||
if (creds?.email) {
|
||||
console.log(chalk.gray(`Logged in as: ${creds.email}`));
|
||||
}
|
||||
|
||||
// Sync API keys
|
||||
console.log(chalk.gray('Syncing API keys...'));
|
||||
const apiKeys = await auth.syncAPIKeys();
|
||||
console.log(chalk.green(`✓ Synced ${apiKeys.length} API keys`));
|
||||
} else {
|
||||
spinner.fail('Login failed');
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(`Login failed: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Logout command
|
||||
program
|
||||
.command('logout')
|
||||
.description('Logout from Hanzo AI platform')
|
||||
.action(async () => {
|
||||
await auth.logout();
|
||||
console.log(chalk.green('Successfully logged out'));
|
||||
});
|
||||
|
||||
// Status command
|
||||
program
|
||||
.command('auth-status')
|
||||
.description('Check authentication status')
|
||||
.action(async () => {
|
||||
if (auth.isAuthenticated()) {
|
||||
const creds = auth.getCredentials();
|
||||
console.log(chalk.green('✓ Authenticated'));
|
||||
if (creds?.email) {
|
||||
console.log(`Email: ${creds.email}`);
|
||||
}
|
||||
if (creds?.userId) {
|
||||
console.log(`User ID: ${creds.userId}`);
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.red('✗ Not authenticated'));
|
||||
console.log(chalk.gray('Run "dev login" to authenticate'));
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize command
|
||||
program
|
||||
.command('init')
|
||||
.description('Initialize Hanzo Dev in the current directory')
|
||||
.option('-f, --force', 'Force initialization even if already initialized')
|
||||
.action(async (options) => {
|
||||
const spinner = ora('Initializing Hanzo Dev...').start();
|
||||
|
||||
try {
|
||||
const configPath = path.join(process.cwd(), '.hanzo-dev');
|
||||
|
||||
if (fs.existsSync(configPath) && !options.force) {
|
||||
spinner.fail('Hanzo Dev already initialized. Use --force to reinitialize.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create directory structure
|
||||
fs.mkdirSync(path.join(configPath, 'logs'), { recursive: true });
|
||||
fs.mkdirSync(path.join(configPath, 'async-results'), { recursive: true });
|
||||
fs.mkdirSync(path.join(configPath, 'worktrees'), { recursive: true });
|
||||
|
||||
// Create default config
|
||||
const defaultConfig = {
|
||||
version: packageJson.version,
|
||||
tools: {
|
||||
claude: { enabled: true },
|
||||
codex: { enabled: true },
|
||||
gemini: { enabled: true },
|
||||
openhands: { enabled: true },
|
||||
aider: { enabled: true }
|
||||
},
|
||||
sync: {
|
||||
enabled: false,
|
||||
platformUrl: 'https://platform.hanzo.ai',
|
||||
projectId: ''
|
||||
},
|
||||
defaults: {
|
||||
idleTimeout: 300000, // 5 minutes
|
||||
maxRuntime: 1800000, // 30 minutes
|
||||
autoCommit: true
|
||||
}
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(configPath, 'config.json'),
|
||||
JSON.stringify(defaultConfig, null, 2)
|
||||
);
|
||||
|
||||
spinner.succeed('Hanzo Dev initialized successfully!');
|
||||
console.log(chalk.gray(`Configuration saved to ${path.join(configPath, 'config.json')}}`));
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to initialize: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Run command - launch a tool
|
||||
program
|
||||
.command('run <tool> [task...]')
|
||||
.description('Run a specific AI tool (claude, codex, gemini, openhands, aider)')
|
||||
.option('-a, --async', 'Run in async mode (long-running background process)')
|
||||
.option('-w, --worktree', 'Create a git worktree for this session')
|
||||
.option('-m, --model <model>', 'Specify the model to use')
|
||||
.option('-f, --files <files...>', 'Files to include in the context')
|
||||
.option('-d, --directory <dir>', 'Working directory for the tool')
|
||||
.option('--no-sync', 'Disable platform sync for this session')
|
||||
.action(async (tool, taskParts, options) => {
|
||||
const task = taskParts.join(' ');
|
||||
const spinner = ora(`Starting ${tool}...`).start();
|
||||
|
||||
try {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.tools[tool]?.enabled) {
|
||||
spinner.fail(`Tool '${tool}' is not enabled or not recognized.`);
|
||||
console.log(chalk.gray('Available tools: claude, codex, gemini, openhands, aider'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.async) {
|
||||
// Use async wrapper for long-running processes
|
||||
const asyncWrapper = new AsyncToolWrapper({
|
||||
idleTimeout: config.defaults.idleTimeout,
|
||||
maxRuntime: config.defaults.maxRuntime,
|
||||
persistResults: true,
|
||||
resultPath: path.join(process.cwd(), '.hanzo-dev', 'async-results')
|
||||
});
|
||||
|
||||
const jobId = await asyncWrapper.createAsyncTool(
|
||||
tool,
|
||||
getToolCommand(tool),
|
||||
getToolArgs(tool, task, options),
|
||||
{ tool, task, options }
|
||||
);
|
||||
|
||||
spinner.succeed(`Started ${tool} in async mode`);
|
||||
console.log(chalk.green(`Job ID: ${jobId}`));
|
||||
console.log(chalk.gray(`Query status with: dev status ${jobId}`));
|
||||
console.log(chalk.gray(`Send input with: dev input ${jobId} "your input"`));
|
||||
} else {
|
||||
// Use regular launcher for interactive sessions
|
||||
const launcher = new DevLauncher({
|
||||
maxInstances: 10,
|
||||
defaultTimeout: config.defaults.maxRuntime,
|
||||
gitRoot: findGitRoot() || process.cwd(),
|
||||
workspacePath: options.directory || process.cwd(),
|
||||
enableSync: config.sync.enabled && options.sync !== false,
|
||||
syncConfig: config.sync
|
||||
});
|
||||
|
||||
await launcher.initialize();
|
||||
|
||||
const instanceId = await launcher.launchTool(
|
||||
tool,
|
||||
task,
|
||||
{
|
||||
workingDirectory: options.directory,
|
||||
useWorktree: options.worktree,
|
||||
model: options.model,
|
||||
files: options.files
|
||||
}
|
||||
);
|
||||
|
||||
spinner.succeed(`Launched ${tool}`);
|
||||
console.log(chalk.green(`Instance ID: ${instanceId}`));
|
||||
|
||||
// Keep process alive for interactive session
|
||||
process.on('SIGINT', async () => {
|
||||
console.log(chalk.yellow('\nShutting down...'));
|
||||
await launcher.stopTool(instanceId);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Wait for tool to complete
|
||||
await new Promise(() => {}); // Keep alive
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to run ${tool}: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Compare command - run multiple tools and compare results
|
||||
program
|
||||
.command('compare <task>')
|
||||
.description('Run the same task on multiple AI tools and compare results')
|
||||
.option('-t, --tools <tools...>', 'Tools to compare (default: all)')
|
||||
.option('-d, --directory <dir>', 'Working directory')
|
||||
.action(async (task, options) => {
|
||||
const spinner = ora('Setting up comparison...').start();
|
||||
|
||||
try {
|
||||
const tools = options.tools || ['claude', 'codex', 'gemini', 'openhands', 'aider'];
|
||||
const manager = new CLIToolManager();
|
||||
await manager.initialize();
|
||||
|
||||
spinner.text = 'Running tools in parallel...';
|
||||
|
||||
const results = await manager.compareResults(task, tools);
|
||||
|
||||
spinner.succeed('Comparison complete!');
|
||||
|
||||
// Display results
|
||||
console.log(chalk.bold('\nComparison Results:'));
|
||||
console.log(chalk.gray('='.repeat(80)));
|
||||
|
||||
for (const [tool, result of results) {
|
||||
console.log(chalk.bold.blue(`\n${tool.toUpperCase()}:`));
|
||||
if (result.error) {
|
||||
console.log(chalk.red(`Error: ${result.error}`));
|
||||
} else {
|
||||
console.log(result.output || 'No output');
|
||||
}
|
||||
console.log(chalk.gray('-'.repeat(80)));
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(`Comparison failed: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Status command - check async job status
|
||||
program
|
||||
.command('status [jobId]')
|
||||
.description('Check status of async jobs')
|
||||
.action(async (jobId) => {
|
||||
try {
|
||||
const asyncWrapper = new AsyncToolWrapper({
|
||||
resultPath: path.join(process.cwd(), '.hanzo-dev', 'async-results')
|
||||
});
|
||||
|
||||
if (jobId) {
|
||||
// Query specific job
|
||||
const result = await asyncWrapper.queryJob(jobId);
|
||||
|
||||
if (!result) {
|
||||
console.log(chalk.red(`Job ${jobId} not found`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`Job ${jobId}:`));
|
||||
console.log(`Status: ${getStatusColor(result.status)(result.status)}`);
|
||||
if (result.duration) {
|
||||
console.log(`Duration: ${formatDuration(result.duration)}`);
|
||||
}
|
||||
console.log(`\nOutput:\n${result.output || 'No output yet'}`);
|
||||
if (result.error) {
|
||||
console.log(chalk.red(`\nErrors:\n${result.error}`));
|
||||
}
|
||||
} else {
|
||||
// Show all active jobs
|
||||
const activeJobs = asyncWrapper.getActiveJobs();
|
||||
|
||||
if (activeJobs.length === 0) {
|
||||
console.log(chalk.gray('No active jobs'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold('Active Jobs:'));
|
||||
for (const job of activeJobs) {
|
||||
console.log(`${chalk.cyan(job.id)} - ${job.toolName} (${getStatusColor(job.status)(job.status)})`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error: ${error.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Input command - send input to async job
|
||||
program
|
||||
.command('input <jobId> <input>')
|
||||
.description('Send input to a running async job')
|
||||
.action(async (jobId, input) => {
|
||||
try {
|
||||
const asyncWrapper = new AsyncToolWrapper({
|
||||
resultPath: path.join(process.cwd(), '.hanzo-dev', 'async-results')
|
||||
});
|
||||
|
||||
const success = asyncWrapper.sendInput(jobId, input);
|
||||
|
||||
if (success) {
|
||||
console.log(chalk.green('Input sent successfully'));
|
||||
} else {
|
||||
console.log(chalk.red('Failed to send input. Job may not be running.'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error: ${error.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Install command - install CLI tools
|
||||
program
|
||||
.command('install [tools...]')
|
||||
.description('Install AI CLI tools')
|
||||
.option('-a, --all', 'Install all supported tools')
|
||||
.action(async (tools, options) => {
|
||||
const allTools = ['claude', 'codex', 'gemini', 'openhands', 'aider'];
|
||||
const toolsToInstall = options.all ? allTools : (tools.length > 0 ? tools : allTools);
|
||||
|
||||
console.log(chalk.bold('Installing Hanzo Dev tools...\n'));
|
||||
|
||||
for (const tool of toolsToInstall) {
|
||||
const spinner = ora(`Installing ${tool}...`).start();
|
||||
|
||||
try {
|
||||
const manager = new CLIToolManager();
|
||||
await manager.initialize();
|
||||
|
||||
spinner.succeed(`${tool} installed successfully`);
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to install ${tool}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green('\n✨ Installation complete!'));
|
||||
});
|
||||
|
||||
// Worktree command - manage git worktrees
|
||||
program
|
||||
.command('worktree')
|
||||
.description('Manage git worktrees for parallel development')
|
||||
.addCommand(
|
||||
new Command('list')
|
||||
.description('List all worktrees')
|
||||
.action(() => {
|
||||
try {
|
||||
const output = execSync('git worktree list', { encoding: 'utf-8' });
|
||||
console.log(output);
|
||||
} catch (error) {
|
||||
console.error(chalk.red('Error listing worktrees. Make sure you are in a git repository.'));
|
||||
}
|
||||
})
|
||||
)
|
||||
.addCommand(
|
||||
new Command('clean')
|
||||
.description('Clean up Hanzo Dev worktrees')
|
||||
.action(() => {
|
||||
try {
|
||||
const output = execSync('git worktree list --porcelain', { encoding: 'utf-8' });
|
||||
const worktrees = output.split('\n\n').filter(Boolean);
|
||||
|
||||
let cleaned = 0;
|
||||
for (const worktree of worktrees) {
|
||||
if (worktree.includes('.worktrees/')) {
|
||||
const path = worktree.split('\n')[0].replace('worktree ', '');
|
||||
execSync(`git worktree remove --force "${path}"`);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green(`Cleaned up ${cleaned} worktrees`));
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error cleaning worktrees: ${error.message}`));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Interactive mode
|
||||
program
|
||||
.command('interactive')
|
||||
.description('Start interactive mode')
|
||||
.action(async () => {
|
||||
console.log(chalk.bold('Welcome to Hanzo Dev Interactive Mode!\n'));
|
||||
|
||||
while (true) {
|
||||
const { action } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'action',
|
||||
message: 'What would you like to do?',
|
||||
choices: [
|
||||
{ name: 'Run a tool', value: 'run' },
|
||||
{ name: 'Compare tools', value: 'compare' },
|
||||
{ name: 'Check job status', value: 'status' },
|
||||
{ name: 'Manage worktrees', value: 'worktree' },
|
||||
{ name: 'Exit', value: 'exit' }
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
if (action === 'exit') {
|
||||
console.log(chalk.gray('Goodbye!'));
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle different actions
|
||||
switch (action) {
|
||||
case 'run':
|
||||
await interactiveRun();
|
||||
break;
|
||||
case 'compare':
|
||||
await interactiveCompare();
|
||||
break;
|
||||
case 'status':
|
||||
await program.parseAsync(['node', 'hanzo-dev', 'status']);
|
||||
break;
|
||||
case 'worktree':
|
||||
await program.parseAsync(['node', 'hanzo-dev', 'worktree', 'list']);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(''); // Add spacing
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function loadConfig(): any {
|
||||
const configPath = path.join(process.cwd(), '.hanzo-dev', 'config.json');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
console.log(chalk.yellow('No config found. Run "hanzo-dev init" first.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
|
||||
function findGitRoot(): string | null {
|
||||
try {
|
||||
const root = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
|
||||
return root;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getToolCommand(tool: string): string {
|
||||
const commands = {
|
||||
claude: 'claude',
|
||||
codex: 'openai',
|
||||
gemini: 'gemini',
|
||||
openhands: 'openhands',
|
||||
aider: 'aider'
|
||||
};
|
||||
return commands[tool] || tool;
|
||||
}
|
||||
|
||||
function getToolArgs(tool: string, task: string, options: any): string[] {
|
||||
const args = [];
|
||||
|
||||
if (options.model) {
|
||||
args.push('--model', options.model);
|
||||
}
|
||||
|
||||
if (options.files) {
|
||||
options.files.forEach(file => args.push(file));
|
||||
}
|
||||
|
||||
args.push(task);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function getStatusColor(status: string): (text: string) => string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return chalk.green;
|
||||
case 'completed':
|
||||
return chalk.blue;
|
||||
case 'failed':
|
||||
return chalk.red;
|
||||
case 'idle':
|
||||
return chalk.yellow;
|
||||
default:
|
||||
return chalk.gray;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
async function interactiveRun(): Promise<void> {
|
||||
const answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'tool',
|
||||
message: 'Which tool would you like to use?',
|
||||
choices: ['claude', 'codex', 'gemini', 'openhands', 'aider']
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'task',
|
||||
message: 'What task would you like to perform?'
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'async',
|
||||
message: 'Run in background (async mode)?',
|
||||
default: false
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'worktree',
|
||||
message: 'Create a separate git worktree?',
|
||||
default: false
|
||||
}
|
||||
]);
|
||||
|
||||
const args = ['node', 'hanzo-dev', 'run', answers.tool, answers.task];
|
||||
if (answers.async) args.push('--async');
|
||||
if (answers.worktree) args.push('--worktree');
|
||||
|
||||
await program.parseAsync(args);
|
||||
}
|
||||
|
||||
async function interactiveCompare(): Promise<void> {
|
||||
const answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'task',
|
||||
message: 'What task would you like to compare?'
|
||||
},
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'tools',
|
||||
message: 'Which tools to compare?',
|
||||
choices: ['claude', 'codex', 'gemini', 'openhands', 'aider'],
|
||||
default: ['claude', 'codex', 'gemini']
|
||||
}
|
||||
]);
|
||||
|
||||
const args = ['node', 'hanzo-dev', 'compare', answers.task];
|
||||
if (answers.tools.length > 0) {
|
||||
args.push('--tools', ...answers.tools);
|
||||
}
|
||||
|
||||
await program.parseAsync(args);
|
||||
}
|
||||
|
||||
// Parse arguments
|
||||
program.parse(process.argv);
|
||||
|
||||
// Show help if no command provided
|
||||
if (!process.argv.slice(2).length) {
|
||||
program.outputHelp();
|
||||
}
|
||||
Reference in New Issue
Block a user