2026-03-28 12:36:06 +00:00
|
|
|
package cmd
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bufio"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
|
"github.com/spf13/viper"
|
|
|
|
|
|
|
|
|
|
"gmgauthier.com/grokkit/config"
|
|
|
|
|
"gmgauthier.com/grokkit/internal/git"
|
|
|
|
|
"gmgauthier.com/grokkit/internal/grok"
|
|
|
|
|
"gmgauthier.com/grokkit/internal/linter"
|
2026-03-28 13:16:31 +00:00
|
|
|
"gmgauthier.com/grokkit/internal/logger" // note: we use package-level funcs
|
|
|
|
|
"gmgauthier.com/grokkit/internal/prompts"
|
2026-03-28 12:36:06 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-28 12:16:17 +00:00
|
|
|
var analyzeCmd = &cobra.Command{
|
2026-03-28 12:36:06 +00:00
|
|
|
Use: "analyze [flags]",
|
|
|
|
|
Short: "Deep project analysis → educational Markdown report",
|
|
|
|
|
Long: `Analyzes the repository root and generates a didactic Markdown report.
|
|
|
|
|
Uses language-specific prompts discovered in .grokkit/prompts/ or ~/.config/grokkit/prompts/.`,
|
|
|
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
|
|
|
dir := viper.GetString("dir")
|
|
|
|
|
if dir == "" {
|
|
|
|
|
dir = "."
|
|
|
|
|
}
|
|
|
|
|
output := viper.GetString("output")
|
|
|
|
|
if output == "" {
|
|
|
|
|
output = "analyze.md"
|
|
|
|
|
}
|
2026-03-28 13:16:31 +00:00
|
|
|
// Fixed: config.GetModel takes (commandName, flagModel)
|
|
|
|
|
model := config.GetModel("analyze", viper.GetString("model"))
|
2026-03-28 12:36:06 +00:00
|
|
|
yes := viper.GetBool("yes")
|
|
|
|
|
|
2026-03-28 13:23:49 +00:00
|
|
|
// Logger (use the package-level logger as done in other commands)
|
|
|
|
|
// (remove the old "log := logger.Get()")
|
|
|
|
|
|
|
|
|
|
// Safety check
|
|
|
|
|
if !git.IsRepo() { // IsRepo takes no arguments
|
2026-03-28 13:16:31 +00:00
|
|
|
logger.Warn("Not inside a git repository. Git metadata in report will be limited.")
|
2026-03-28 12:36:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 1. Discover source files
|
|
|
|
|
files, err := discoverSourceFiles(dir)
|
|
|
|
|
if err != nil {
|
2026-03-28 13:16:31 +00:00
|
|
|
logger.Error("Failed to discover source files", "dir", dir, "error", err)
|
2026-03-28 12:36:06 +00:00
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
if len(files) == 0 {
|
|
|
|
|
fmt.Printf("No supported source files found in %s\n", dir)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:16:31 +00:00
|
|
|
// 2. Detect primary language
|
2026-03-28 12:36:06 +00:00
|
|
|
lang := linter.DetectPrimaryLanguage(files)
|
|
|
|
|
if lang == "" {
|
|
|
|
|
lang = "unknown"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Load language-specific prompt (project → global)
|
|
|
|
|
promptPath, promptContent, err := prompts.LoadAnalysisPrompt(dir, lang)
|
|
|
|
|
if err != nil {
|
|
|
|
|
fmt.Printf("Error: Could not find analysis prompt for language '%s'.\n\n", lang)
|
|
|
|
|
fmt.Println("Create one of the following files:")
|
|
|
|
|
fmt.Printf(" %s\n", filepath.Join(dir, ".grokkit", "prompts", lang+".md"))
|
|
|
|
|
fmt.Printf(" %s\n", filepath.Join(os.Getenv("HOME"), ".config", "grokkit", "prompts", lang+".md"))
|
|
|
|
|
fmt.Println("\nExample starter content:")
|
|
|
|
|
fmt.Println(`You are an expert ` + lang + ` educator...`)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
2026-03-28 13:16:31 +00:00
|
|
|
logger.Info("Loaded analysis prompt", "language", lang, "path", promptPath)
|
2026-03-28 13:44:10 +00:00
|
|
|
// Improve prompt with the project name if possible
|
|
|
|
|
projectName := filepath.Base(dir)
|
|
|
|
|
if projectName == "." {
|
|
|
|
|
projectName = "Current Project"
|
|
|
|
|
}
|
|
|
|
|
promptContent = strings.Replace(promptContent, "[Inferred Project Name]", projectName, 1)
|
2026-03-28 12:36:06 +00:00
|
|
|
|
2026-03-28 13:16:31 +00:00
|
|
|
// 4. Build rich project context
|
2026-03-28 12:36:06 +00:00
|
|
|
context := buildProjectContext(dir, files)
|
|
|
|
|
|
2026-03-28 13:16:31 +00:00
|
|
|
// 5. Call Grok — use the exact working pattern you provided
|
2026-03-28 12:36:06 +00:00
|
|
|
messages := []map[string]string{
|
|
|
|
|
{"role": "system", "content": promptContent},
|
|
|
|
|
{"role": "user", "content": "Analyze the following project and generate the full educational Markdown report:\n\n" + context},
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:16:31 +00:00
|
|
|
// Fixed: NewClient() + Stream() (or StreamSilent if you prefer no live output)
|
|
|
|
|
// For a long report, StreamSilent is usually better (no live printing)
|
|
|
|
|
report := grok.NewClient().StreamSilent(messages, model)
|
2026-03-28 12:36:06 +00:00
|
|
|
|
|
|
|
|
// 6. Transactional preview + confirmation
|
|
|
|
|
if !yes {
|
|
|
|
|
fmt.Println("\n=== Proposed Analysis Report Preview (first 60 lines) ===")
|
|
|
|
|
previewLines(report, 60)
|
|
|
|
|
fmt.Printf("\nWrite report to %s? (y/N): ", output)
|
|
|
|
|
var confirm string
|
2026-03-28 13:16:31 +00:00
|
|
|
_, err := fmt.Scanln(&confirm)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-28 12:36:06 +00:00
|
|
|
if !strings.HasPrefix(strings.ToLower(confirm), "y") {
|
|
|
|
|
fmt.Println("Analysis cancelled by user.")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 7. Output
|
|
|
|
|
if output == "-" {
|
|
|
|
|
fmt.Println(report)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := os.WriteFile(output, []byte(report), 0644); err != nil {
|
2026-03-28 13:16:31 +00:00
|
|
|
logger.Error("Failed to write report", "file", output, "error", err)
|
2026-03-28 12:36:06 +00:00
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fmt.Printf("✅ Educational analysis report written to %s\n", output)
|
|
|
|
|
fmt.Println("Review it with: less " + output)
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func init() {
|
|
|
|
|
analyzeCmd.Flags().String("dir", ".", "Repository root to analyze")
|
|
|
|
|
analyzeCmd.Flags().StringP("output", "o", "analyze.md", "Output file (use - for stdout)")
|
|
|
|
|
analyzeCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
|
|
|
|
|
|
|
|
|
rootCmd.AddCommand(analyzeCmd)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:23:49 +00:00
|
|
|
// discoverSourceFiles walks the directory and collects all supported source files.
|
|
|
|
|
// It skips common noise directories but DOES descend into cmd/, internal/, etc.
|
2026-03-28 12:36:06 +00:00
|
|
|
func discoverSourceFiles(root string) ([]string, error) {
|
|
|
|
|
var files []string
|
|
|
|
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-03-28 13:23:49 +00:00
|
|
|
|
|
|
|
|
// Skip common hidden/noise directories but still allow descent into source dirs
|
|
|
|
|
name := info.Name()
|
|
|
|
|
if info.IsDir() {
|
|
|
|
|
if strings.HasPrefix(name, ".") && name != "." && name != ".." || // skip .git, .grokkit (except we want .grokkit/prompts? but for source we skip)
|
|
|
|
|
name == "node_modules" || name == "vendor" || name == "build" || name == "dist" {
|
|
|
|
|
return filepath.SkipDir
|
2026-03-28 12:36:06 +00:00
|
|
|
}
|
2026-03-28 13:23:49 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if this is a supported source file
|
|
|
|
|
if _, detectErr := linter.DetectLanguage(path); detectErr == nil {
|
|
|
|
|
files = append(files, path)
|
2026-03-28 12:36:06 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
return files, err
|
2026-03-28 12:16:17 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:23:49 +00:00
|
|
|
// previewLines prints the first N lines of the report (unchanged)
|
2026-03-28 12:36:06 +00:00
|
|
|
func previewLines(content string, n int) {
|
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
|
|
|
|
for i := 0; i < n && scanner.Scan(); i++ {
|
|
|
|
|
fmt.Println(scanner.Text())
|
|
|
|
|
}
|
|
|
|
|
if scanner.Err() != nil {
|
|
|
|
|
fmt.Println("... (preview truncated)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:23:49 +00:00
|
|
|
// buildProjectContext — small improvement for better context (optional but nice)
|
2026-03-28 12:36:06 +00:00
|
|
|
func buildProjectContext(dir string, files []string) string {
|
|
|
|
|
var sb strings.Builder
|
2026-03-28 12:46:04 +00:00
|
|
|
sb.WriteString("Project Root: " + dir + "\n\n")
|
2026-03-28 13:23:49 +00:00
|
|
|
sb.WriteString(fmt.Sprintf("Total source files discovered: %d\n\n", len(files)))
|
2026-03-28 12:46:04 +00:00
|
|
|
|
2026-03-28 13:23:49 +00:00
|
|
|
sb.WriteString("Key files (top-level view):\n")
|
2026-03-28 12:46:04 +00:00
|
|
|
for _, f := range files {
|
|
|
|
|
rel, _ := filepath.Rel(dir, f)
|
2026-03-28 13:23:49 +00:00
|
|
|
if strings.Count(rel, string(filepath.Separator)) <= 2 {
|
2026-03-28 12:46:04 +00:00
|
|
|
sb.WriteString(" - " + rel + "\n")
|
|
|
|
|
}
|
|
|
|
|
if sb.Len() > 2500 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-28 12:36:06 +00:00
|
|
|
|
2026-03-28 13:23:49 +00:00
|
|
|
// Git remotes
|
2026-03-28 12:46:04 +00:00
|
|
|
if out, err := git.Run([]string{"remote", "-v"}); err == nil && out != "" {
|
|
|
|
|
sb.WriteString("\nGit Remotes:\n" + out + "\n")
|
2026-03-28 12:36:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sb.String()
|
|
|
|
|
}
|