├── go.mod ├── go.sum ├── README.md └── app.go /go.mod: -------------------------------------------------------------------------------- 1 | module jscollab 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.7 6 | 7 | require ( 8 | github.com/joho/godotenv v1.5.1 9 | google.golang.org/genai v0.6.0 10 | ) 11 | 12 | require ( 13 | cloud.google.com/go v0.116.0 // indirect 14 | cloud.google.com/go/compute/metadata v0.5.0 // indirect 15 | github.com/google/go-cmp v0.6.0 // indirect 16 | github.com/gorilla/websocket v1.5.3 // indirect 17 | golang.org/x/oauth2 v0.23.0 // indirect 18 | golang.org/x/sys v0.25.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= 2 | cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= 3 | cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= 4 | cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= 5 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 6 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 7 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 8 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 9 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 10 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 11 | golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= 12 | golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 13 | golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= 14 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 15 | google.golang.org/genai v0.6.0 h1:S9eDmXHPPqiWrKO2G7ydTNQ70fG1y1+ttR6zsFCPJd0= 16 | google.golang.org/genai v0.6.0/go.mod h1:yPyKKBezIg2rqZziLhHQ5CD62HWr7sLDLc2PDzdrNVs= 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jscollab 2 | 3 | A powerful JavaScript analysis tool that uses Google's Gemini AI to provide detailed code explanations and summaries. 4 | 5 | ## Features 6 | 7 | - Automatically processes JavaScript files from a target directory 8 | - Intelligently filters large files that exceed the AI context window 9 | - Generates detailed analysis for each JavaScript file including: 10 | - Concise summary of the code's purpose 11 | - Detailed explanation of functionality 12 | - Step-by-step breakdown of the code's operation 13 | - Creates a table of contents with summaries of all processed files 14 | - Parallel processing for improved performance 15 | - Error handling to ensure partial results are preserved even when some files fail 16 | 17 | ## Prerequisites 18 | 19 | - Go 1.16+ 20 | - Google Gemini API key 21 | 22 | ## Installation 23 | 24 | 1. Clone the repository: 25 | 26 | ``` 27 | git clone https://github.com/xssdoctor/jscollab.git 28 | cd jscollab 29 | ``` 30 | 31 | 2. Build the project: 32 | 33 | ``` 34 | go build -o jscollab 35 | ``` 36 | 37 | 3. Run the tool once to create the configuration directory: 38 | 39 | ``` 40 | ./jscollab /path/to/js/files 41 | ``` 42 | 43 | This will create a directory at `~/.config/jscollab` with a template `.env` file. 44 | 45 | 4. Update the `.env` file with your Gemini API key: 46 | ``` 47 | # Edit ~/.config/jscollab/.env 48 | GEMINI_API_KEY=your_api_key_here 49 | ``` 50 | 51 | ## Usage 52 | 53 | Run the tool by providing the path to the directory containing JavaScript files: 54 | 55 | ``` 56 | ./jscollab /path/to/js/files 57 | ``` 58 | 59 | ### Output 60 | 61 | The tool generates the following outputs in the `output` directory: 62 | 63 | - `table_of_contents.txt`: A summary of all processed files 64 | - Individual analysis files for each JavaScript file (named after the original file) 65 | - `out_of_context_window.txt`: List of files that were too large to process 66 | 67 | ## How It Works 68 | 69 | 1. Scans the target directory for JavaScript files 70 | 2. Filters out files that exceed the context window threshold (900,000 characters) 71 | 3. Sends each file to Google's Gemini AI for analysis 72 | 4. Extracts key information from the AI's response 73 | 5. Generates a comprehensive table of contents 74 | 6. Saves all analysis files for future reference 75 | 76 | ## Configuration 77 | 78 | The tool automatically creates and uses a configuration directory at `~/.config/jscollab`. This directory contains: 79 | 80 | - `.env` file: Stores your Gemini API key 81 | 82 | You can adjust the threshold for file size in the source code by modifying the `threshold` constant. 83 | 84 | ## License 85 | 86 | [MIT License](LICENSE) 87 | 88 | ## Created By 89 | 90 | [xssdoctor](https://github.com/xssdoctor) 91 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "sync" 14 | "time" 15 | 16 | "github.com/joho/godotenv" 17 | gemini "google.golang.org/genai" 18 | ) 19 | 20 | var geminiKey string 21 | 22 | type FileInfo struct { 23 | Path string 24 | withinWindow bool 25 | } 26 | 27 | func init() { 28 | // Get user's home directory 29 | homeDir, err := os.UserHomeDir() 30 | if err != nil { 31 | log.Printf("Error getting user home directory: %v", err) 32 | } else { 33 | // Create ~/.config/jscollab directory if it doesn't exist 34 | configDir := filepath.Join(homeDir, ".config", "jscollab") 35 | 36 | // Check if directory exists before creating it 37 | if _, err := os.Stat(configDir); os.IsNotExist(err) { 38 | err = os.MkdirAll(configDir, os.ModePerm) 39 | if err != nil { 40 | log.Printf("Error creating config directory %s: %v", configDir, err) 41 | } else { 42 | log.Printf("Created config directory at %s", configDir) 43 | } 44 | } 45 | 46 | // Check if .env file exists, if not create it with template content 47 | envFile := filepath.Join(configDir, ".env") 48 | if _, err := os.Stat(envFile); os.IsNotExist(err) { 49 | // Create a template .env file 50 | templateContent := "GEMINI_API_KEY=YOURAPIKEY" 51 | err = os.WriteFile(envFile, []byte(templateContent), 0600) 52 | if err != nil { 53 | log.Printf("Error creating template .env file: %v", err) 54 | } else { 55 | log.Printf("Created template .env file at %s. Please update with your actual API key.", envFile) 56 | } 57 | } 58 | 59 | // Try to load from ~/.config/jscollab/.env 60 | if err := godotenv.Load(envFile); err != nil { 61 | log.Printf("No .env file found at %s", envFile) 62 | } 63 | } 64 | 65 | geminiKey = os.Getenv("GEMINI_API_KEY") 66 | folderPath := "output" 67 | err = os.MkdirAll(folderPath, os.ModePerm) 68 | if err != nil { 69 | log.Fatalf("Error creating output folder: %v", err) 70 | } 71 | } 72 | 73 | const threshold = 900000 74 | 75 | func callGeminiAPI(model, prompt, key string) (string, error) { 76 | // Create a context with timeout 77 | ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) 78 | defer cancel() 79 | 80 | // Split the prompt into system and user parts (Gemini doesn't support system prompts directly) 81 | parts := strings.SplitN(prompt, " ", 4) // "Process the following input: actual-input" 82 | systemPrompt := strings.Join(parts[:3], " ") 83 | userPrompt := "" 84 | if len(parts) > 3 { 85 | userPrompt = parts[3] 86 | } 87 | 88 | // Combine system and user prompts for Gemini 89 | geminiPrompt := systemPrompt 90 | if userPrompt != "" { 91 | geminiPrompt = fmt.Sprintf("%s\n\n%s", systemPrompt, userPrompt) 92 | } 93 | 94 | client, err := gemini.NewClient(ctx, &gemini.ClientConfig{ 95 | APIKey: key, 96 | Backend: gemini.BackendGeminiAPI, 97 | }) 98 | if err != nil { 99 | return "", fmt.Errorf("error creating Gemini client: %v", err) 100 | } 101 | 102 | // Generate content without config to avoid type issues 103 | result, err := client.Models.GenerateContent(ctx, model, gemini.Text(geminiPrompt), nil) 104 | if err != nil { 105 | return "", fmt.Errorf("error generating content: %v", err) 106 | } 107 | 108 | if result == nil { 109 | return "", errors.New("received nil result from Gemini API") 110 | } 111 | 112 | resultText := result.Text() 113 | 114 | return resultText, nil 115 | } 116 | 117 | func wordCount(path string) (int, error) { 118 | file, err := os.Open(path) 119 | if err != nil { 120 | return 0, err 121 | } 122 | defer file.Close() 123 | 124 | // Get file size as a rough estimate first 125 | fileInfo, err := file.Stat() 126 | if err != nil { 127 | return 0, err 128 | } 129 | 130 | // If file is larger than threshold, return threshold+1 to avoid actual counting 131 | if fileInfo.Size() > threshold { 132 | return threshold + 1, nil 133 | } 134 | 135 | reader := bufio.NewReader(file) 136 | count := 0 137 | 138 | for { 139 | line, err := reader.ReadString('\n') 140 | count += len(strings.Fields(line)) 141 | 142 | if err == io.EOF { 143 | break 144 | } 145 | if err != nil { 146 | return count, err 147 | } 148 | 149 | // Early exit if we've exceeded the threshold 150 | if count > threshold { 151 | break 152 | } 153 | } 154 | 155 | return count, nil 156 | } 157 | 158 | func processFiles(dir string) ([]FileInfo, error) { 159 | files := []FileInfo{} 160 | 161 | err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 162 | if err != nil { 163 | return err 164 | } 165 | 166 | if !info.IsDir() && strings.HasSuffix(info.Name(), ".js") { 167 | count, err := wordCount(path) 168 | if err != nil { 169 | return err 170 | } 171 | 172 | relPath, err := filepath.Rel(dir, path) 173 | if err != nil { 174 | return err 175 | } 176 | 177 | fileInfo := FileInfo{ 178 | Path: relPath, 179 | withinWindow: count < threshold, 180 | } 181 | files = append(files, fileInfo) 182 | } 183 | return nil 184 | }) 185 | 186 | return files, err 187 | } 188 | 189 | func saveFile(files []string, folderPath string) error { 190 | filePath := "out_of_context_window.txt" 191 | file, err := os.Create(filePath) 192 | if err != nil { 193 | return err 194 | } 195 | defer file.Close() 196 | writer := bufio.NewWriter(file) 197 | for _, file := range files { 198 | _, err := writer.WriteString(file + "\n") 199 | if err != nil { 200 | return err 201 | } 202 | } 203 | err = writer.Flush() 204 | if err != nil { 205 | return err 206 | } 207 | return nil 208 | } 209 | 210 | func separateFiles(files []FileInfo) ([]string, error) { 211 | filesToSendToAi := []string{} 212 | filesOutOfWindow := []string{} 213 | for _, file := range files { 214 | if file.withinWindow { 215 | filesToSendToAi = append(filesToSendToAi, file.Path) 216 | } else { 217 | filesOutOfWindow = append(filesOutOfWindow, file.Path) 218 | } 219 | } 220 | folderPath := "output" 221 | err := saveFile(filesOutOfWindow, folderPath) 222 | if err != nil { 223 | return nil, err 224 | } 225 | return filesToSendToAi, nil 226 | } 227 | 228 | func sendFilesToAi(files []string, srcDir string) (map[string]string, error) { 229 | var wg sync.WaitGroup 230 | var mu sync.Mutex 231 | var errors []string 232 | 233 | summaries := make(map[string]string) 234 | 235 | for _, file := range files { 236 | wg.Add(1) 237 | go func(filePath string) { 238 | defer wg.Done() 239 | 240 | // Read the file content 241 | fullPath := filepath.Join(srcDir, filePath) 242 | content, err := os.ReadFile(fullPath) 243 | if err != nil { 244 | mu.Lock() 245 | errors = append(errors, fmt.Sprintf("Error reading file %s: %v", filePath, err)) 246 | mu.Unlock() 247 | fmt.Printf("Error reading file %s: %v\n", filePath, err) 248 | return 249 | } 250 | 251 | // Create the prompt with the file content 252 | prompt := fmt.Sprintf(` 253 | # IDENTITY and PURPOSE 254 | You are an expert in JavaScript. You are given a JavaScript file. Please analyze the file and provide a summary and then a detailed explanation of the code. Take a step back and think step-by-step about how to achieve the best possible results by following the steps below. 255 | 256 | # STEPS 257 | 1. Read the file and understand the code 258 | 2. provide a one to two sentence summary of the code 259 | 3. provide a detailed explanation of the code 260 | 4. provide a step by step breakdown of the code 261 | 262 | # OUTPUT INSTRUCTIONS 263 | 1. The first line of output should be a one to two sentence summary of the code 264 | 2. after two spaces, provide a detailed explanation of the code 265 | 3. after four spaces, provide a step by step breakdown of the code 266 | 267 | # OUTPUT EXAMPLE 268 | Summary: 269 | Summary of the code 270 | 271 | Explanation: 272 | Detailed explanation of the code 273 | 274 | Breakdown: 275 | Step by step breakdown of the code 276 | 277 | # INPUT 278 | %s 279 | 280 | `, content) 281 | 282 | // Call Gemini API 283 | response, err := callGeminiAPI("gemini-2.0-flash-lite", prompt, geminiKey) 284 | if err != nil { 285 | mu.Lock() 286 | errors = append(errors, fmt.Sprintf("Error calling Gemini API for %s: %v", filePath, err)) 287 | mu.Unlock() 288 | fmt.Printf("Error calling Gemini API for %s: %v\n", filePath, err) 289 | return 290 | } 291 | 292 | // Extract summary from response 293 | summary := extractSummary(response) 294 | 295 | // Add to summaries map 296 | mu.Lock() 297 | summaries[filePath] = summary 298 | mu.Unlock() 299 | 300 | // Prepare output file path 301 | outFileName := strings.TrimSuffix(filepath.Base(filePath), ".js") + "_analysis.txt" 302 | outPath := filepath.Join("output", outFileName) 303 | 304 | // Save the response to a file 305 | err = os.WriteFile(outPath, []byte(response), 0644) 306 | if err != nil { 307 | mu.Lock() 308 | errors = append(errors, fmt.Sprintf("Error saving response for %s: %v", filePath, err)) 309 | mu.Unlock() 310 | fmt.Printf("Error saving response for %s: %v\n", filePath, err) 311 | return 312 | } 313 | 314 | fmt.Printf("Successfully processed %s\n", filePath) 315 | }(file) 316 | } 317 | wg.Wait() 318 | 319 | if len(errors) > 0 { 320 | return summaries, fmt.Errorf("encountered %d errors during processing", len(errors)) 321 | } 322 | return summaries, nil 323 | } 324 | 325 | // extractSummary extracts the summary from the Gemini API response 326 | func extractSummary(response string) string { 327 | lines := strings.Split(response, "\n") 328 | 329 | // Look for the summary section 330 | for i, line := range lines { 331 | if strings.HasPrefix(strings.ToLower(line), "summary:") { 332 | // Get the next line which should be the summary 333 | if i+1 < len(lines) { 334 | return strings.TrimSpace(lines[i+1]) 335 | } 336 | } 337 | } 338 | 339 | // If no summary section found, try to get the first non-empty line 340 | for _, line := range lines { 341 | if trimmed := strings.TrimSpace(line); trimmed != "" { 342 | return trimmed 343 | } 344 | } 345 | 346 | return "No summary available" 347 | } 348 | 349 | // createTableOfContents creates a table of contents file with all summaries 350 | func createTableOfContents(summaries map[string]string) error { 351 | outPath := "table_of_contents.txt" 352 | 353 | // Create the file content 354 | var content strings.Builder 355 | 356 | for filePath, summary := range summaries { 357 | content.WriteString(fmt.Sprintf("%s: %s\n\n", filePath, summary)) 358 | } 359 | 360 | // Write to file 361 | return os.WriteFile(outPath, []byte(content.String()), 0644) 362 | } 363 | 364 | func main() { 365 | if len(os.Args) != 2 { 366 | fmt.Println("Usage: tool ") 367 | os.Exit(1) 368 | } 369 | 370 | srcFolder := os.Args[1] 371 | 372 | files, err := processFiles(srcFolder) 373 | if err != nil { 374 | fmt.Println("Error:", err) 375 | os.Exit(1) 376 | } 377 | 378 | filesToSendToAi, err := separateFiles(files) 379 | if err != nil { 380 | fmt.Println("Error:", err) 381 | os.Exit(1) 382 | } 383 | 384 | // Process the files that are within threshold 385 | fmt.Printf("Found %d files under threshold\n", len(filesToSendToAi)) 386 | 387 | // Send files to AI and get summaries 388 | summaries, err := sendFilesToAi(filesToSendToAi, srcFolder) 389 | 390 | // Create table of contents even if there were errors 391 | if len(summaries) > 0 { 392 | if tocErr := createTableOfContents(summaries); tocErr != nil { 393 | fmt.Println("Error creating table of contents:", tocErr) 394 | } else { 395 | fmt.Println("Table of contents created successfully!") 396 | } 397 | } else { 398 | fmt.Println("No summaries generated, skipping table of contents") 399 | } 400 | 401 | // Report any errors after creating the table of contents 402 | if err != nil { 403 | fmt.Println("Error sending files to AI:", err) 404 | os.Exit(1) 405 | } 406 | 407 | fmt.Println("Processing complete!") 408 | } 409 | --------------------------------------------------------------------------------