diff options
Diffstat (limited to 'internal/showcase')
| -rw-r--r-- | internal/showcase/ai_context.go | 338 | ||||
| -rw-r--r-- | internal/showcase/code_extractor.go | 228 | ||||
| -rw-r--r-- | internal/showcase/images.go | 78 | ||||
| -rw-r--r-- | internal/showcase/language_detector.go | 214 | ||||
| -rw-r--r-- | internal/showcase/metadata.go | 33 | ||||
| -rw-r--r-- | internal/showcase/showcase.go | 311 |
6 files changed, 636 insertions, 566 deletions
diff --git a/internal/showcase/ai_context.go b/internal/showcase/ai_context.go index f418894..1c812f8 100644 --- a/internal/showcase/ai_context.go +++ b/internal/showcase/ai_context.go @@ -1,184 +1,214 @@ package showcase import ( - "fmt" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" ) // buildAIInputContext prepares a textual context for AI tools when no README exists. // It returns a string to be piped to the AI tool's stdin and a boolean indicating // whether this was sourced from an actual README (true) or synthesized (false). func buildAIInputContext(repoPath string) (string, bool) { - // 1) Try to load a README first - readmeFiles := []string{ - "README.md", "readme.md", "Readme.md", - "README.MD", "README.txt", "readme.txt", - "README", "readme", - } - for _, f := range readmeFiles { - p := filepath.Join(repoPath, f) - if b, err := os.ReadFile(p); err == nil { - return string(b), true - } - } + // 1) Try to load a README first + readmeFiles := []string{ + "README.md", "readme.md", "Readme.md", + "README.MD", "README.txt", "readme.txt", + "README", "readme", + } + for _, f := range readmeFiles { + p := filepath.Join(repoPath, f) + if b, err := os.ReadFile(p); err == nil { + return string(b), true + } + } - // 2) No README: synthesize compact context - var sb strings.Builder + // 2) No README: synthesize compact context + var sb strings.Builder - // File tree (depth-limited) - sb.WriteString("[CONTEXT]\n") - sb.WriteString("Repository does not contain a README.\n") - sb.WriteString("The following is a compact file tree and key manifests/snippets.\n\n") + // File tree (depth-limited) + sb.WriteString("[CONTEXT]\n") + sb.WriteString("Repository does not contain a README.\n") + sb.WriteString("The following is a compact file tree and key manifests/snippets.\n\n") - sb.WriteString("FILE TREE (depth 2):\n") - tree := listFileTree(repoPath, 2, 200) - for _, line := range tree { - sb.WriteString("- ") - sb.WriteString(line) - sb.WriteString("\n") - } - sb.WriteString("\n") + sb.WriteString("FILE TREE (depth 2):\n") + tree := listFileTree(repoPath, 2, 200) + for _, line := range tree { + sb.WriteString("- ") + sb.WriteString(line) + sb.WriteString("\n") + } + sb.WriteString("\n") - // Key manifests we often care about - manifests := []string{ - "go.mod", "go.sum", "package.json", "Cargo.toml", "Cargo.lock", - "pyproject.toml", "requirements.txt", "Makefile", "Dockerfile", - "build.gradle", "pom.xml", "composer.json", - } - wroteHeader := false - for _, m := range manifests { - p := filepath.Join(repoPath, m) - if b, err := os.ReadFile(p); err == nil { - if !wroteHeader { - sb.WriteString("KEY MANIFESTS:\n") - wroteHeader = true - } - sb.WriteString(fmt.Sprintf("--- %s ---\n", m)) - sb.WriteString(trimTo(string(b), 2000)) - sb.WriteString("\n\n") - } - } + // Key manifests we often care about + manifests := []string{ + "go.mod", "go.sum", "package.json", "Cargo.toml", "Cargo.lock", + "pyproject.toml", "requirements.txt", "Makefile", "Dockerfile", + "build.gradle", "pom.xml", "composer.json", + } + wroteHeader := false + for _, m := range manifests { + p := filepath.Join(repoPath, m) + if b, err := os.ReadFile(p); err == nil { + if !wroteHeader { + sb.WriteString("KEY MANIFESTS:\n") + wroteHeader = true + } + sb.WriteString(fmt.Sprintf("--- %s ---\n", m)) + sb.WriteString(trimTo(string(b), 2000)) + sb.WriteString("\n\n") + } + } - // Source hints: capture first main-ish entry file snippets - // Priority: Go main, Rust main, Node entry, Python main - candidates := []string{ - "cmd", // Go convention - "main.go", - "cmd/main.go", - "src/main.rs", - "index.js", - "src/index.js", - "main.py", - "src/main.py", - } - wroteSrc := false - for _, c := range candidates { - p := filepath.Join(repoPath, c) - info, err := os.Stat(p) - if err != nil { - continue - } - if info.IsDir() { - // collect a few go files under cmd/*/main.go - if c == "cmd" { - _ = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error { - if err != nil { return nil } - if d.IsDir() { return nil } - base := filepath.Base(path) - if base == "main.go" { - if b, e := os.ReadFile(path); e == nil { - if !wroteSrc { sb.WriteString("PRIMARY SOURCE SNIPPETS:\n"); wroteSrc = true } - rel, _ := filepath.Rel(repoPath, path) - sb.WriteString(fmt.Sprintf("--- %s ---\n", rel)) - sb.WriteString(trimTo(string(b), 2000)) - sb.WriteString("\n\n") - } - } - return nil - }) - } - continue - } - if b, e := os.ReadFile(p); e == nil { - if !wroteSrc { sb.WriteString("PRIMARY SOURCE SNIPPETS:\n"); wroteSrc = true } - rel, _ := filepath.Rel(repoPath, p) - sb.WriteString(fmt.Sprintf("--- %s ---\n", rel)) - sb.WriteString(trimTo(string(b), 2000)) - sb.WriteString("\n\n") - } - } + // Source hints: capture first main-ish entry file snippets + // Priority: Go main, Rust main, Node entry, Python main + candidates := []string{ + "cmd", // Go convention + "main.go", + "cmd/main.go", + "src/main.rs", + "index.js", + "src/index.js", + "main.py", + "src/main.py", + } + wroteSrc := false + for _, c := range candidates { + p := filepath.Join(repoPath, c) + info, err := os.Stat(p) + if err != nil { + continue + } + if info.IsDir() { + // collect a few go files under cmd/*/main.go + if c == "cmd" { + _ = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + return nil + } + base := filepath.Base(path) + if base == "main.go" { + if b, e := os.ReadFile(path); e == nil { + if !wroteSrc { + sb.WriteString("PRIMARY SOURCE SNIPPETS:\n") + wroteSrc = true + } + rel, _ := filepath.Rel(repoPath, path) + sb.WriteString(fmt.Sprintf("--- %s ---\n", rel)) + sb.WriteString(trimTo(string(b), 2000)) + sb.WriteString("\n\n") + } + } + return nil + }) + } + continue + } + if b, e := os.ReadFile(p); e == nil { + if !wroteSrc { + sb.WriteString("PRIMARY SOURCE SNIPPETS:\n") + wroteSrc = true + } + rel, _ := filepath.Rel(repoPath, p) + sb.WriteString(fmt.Sprintf("--- %s ---\n", rel)) + sb.WriteString(trimTo(string(b), 2000)) + sb.WriteString("\n\n") + } + } - // Fallback: include a few top-level .go, .rs, .py, .js files if we still have nothing - if !wroteSrc { - topFiles := listTopFiles(repoPath, []string{".go", ".rs", ".py", ".js", ".ts", ".tsx"}, 5) - for _, f := range topFiles { - if b, e := os.ReadFile(filepath.Join(repoPath, f)); e == nil { - if !wroteSrc { sb.WriteString("PRIMARY SOURCE SNIPPETS:\n"); wroteSrc = true } - sb.WriteString(fmt.Sprintf("--- %s ---\n", f)) - sb.WriteString(trimTo(string(b), 2000)) - sb.WriteString("\n\n") - } - } - } + // Fallback: include a few top-level .go, .rs, .py, .js files if we still have nothing + if !wroteSrc { + topFiles := listTopFiles(repoPath, []string{".go", ".rs", ".py", ".js", ".ts", ".tsx"}, 5) + for _, f := range topFiles { + if b, e := os.ReadFile(filepath.Join(repoPath, f)); e == nil { + if !wroteSrc { + sb.WriteString("PRIMARY SOURCE SNIPPETS:\n") + wroteSrc = true + } + sb.WriteString(fmt.Sprintf("--- %s ---\n", f)) + sb.WriteString(trimTo(string(b), 2000)) + sb.WriteString("\n\n") + } + } + } - // Instruction to the model - sb.WriteString("[TASK]\n") - sb.WriteString("Summarize this project in 1–2 paragraphs: what it does, why it's useful, and how it's implemented. Mention notable tech choices. Be concise and informative.\n") + // Instruction to the model + sb.WriteString("[TASK]\n") + sb.WriteString("Summarize this project in 1–2 paragraphs: what it does, why it's useful, and how it's implemented. Mention notable tech choices. Be concise and informative.\n") - return sb.String(), false + return sb.String(), false } // listFileTree returns a sorted list of relative paths up to a given depth and limit. func listFileTree(root string, maxDepth int, maxEntries int) []string { - var entries []string - var count int - _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { return nil } - if path == root { return nil } - rel, e := filepath.Rel(root, path) - if e != nil { return nil } - // depth check - depth := 1 + strings.Count(rel, string(os.PathSeparator)) - if depth > maxDepth { return fs.SkipDir } - entries = append(entries, rel) - count++ - if count >= maxEntries { return fs.SkipDir } - return nil - }) - sort.Strings(entries) - if len(entries) > maxEntries { - entries = entries[:maxEntries] - } - return entries + var entries []string + var count int + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if path == root { + return nil + } + rel, e := filepath.Rel(root, path) + if e != nil { + return nil + } + // depth check + depth := 1 + strings.Count(rel, string(os.PathSeparator)) + if depth > maxDepth { + return fs.SkipDir + } + entries = append(entries, rel) + count++ + if count >= maxEntries { + return fs.SkipDir + } + return nil + }) + sort.Strings(entries) + if len(entries) > maxEntries { + entries = entries[:maxEntries] + } + return entries } // listTopFiles lists top-level files with certain extensions up to a limit. func listTopFiles(root string, exts []string, limit int) []string { - dir, err := os.ReadDir(root) - if err != nil { return nil } - var out []string - for _, e := range dir { - if e.IsDir() { continue } - name := e.Name() - for _, x := range exts { - if strings.HasSuffix(strings.ToLower(name), strings.ToLower(x)) { - out = append(out, name) - break - } - } - if len(out) >= limit { break } - } - sort.Strings(out) - return out + dir, err := os.ReadDir(root) + if err != nil { + return nil + } + var out []string + for _, e := range dir { + if e.IsDir() { + continue + } + name := e.Name() + for _, x := range exts { + if strings.HasSuffix(strings.ToLower(name), strings.ToLower(x)) { + out = append(out, name) + break + } + } + if len(out) >= limit { + break + } + } + sort.Strings(out) + return out } // trimTo soft-limits content length for inclusion in AI context. func trimTo(s string, max int) string { - if len(s) <= max { return s } - return s[:max] + "\n... [truncated]" + if len(s) <= max { + return s + } + return s[:max] + "\n... [truncated]" } - diff --git a/internal/showcase/code_extractor.go b/internal/showcase/code_extractor.go index 91a0a78..fbf17f6 100644 --- a/internal/showcase/code_extractor.go +++ b/internal/showcase/code_extractor.go @@ -22,34 +22,34 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str // Get the primary language (highest percentage) primaryLang := languages[0].Name - + // Define file extensions for each language langExtensions := map[string][]string{ - "Go": {".go"}, - "Python": {".py"}, - "JavaScript": {".js"}, - "TypeScript": {".ts"}, - "Java": {".java"}, - "C": {".c", ".h"}, - "C++": {".cpp", ".cc", ".cxx", ".hpp"}, - "C/C++": {".h"}, - "C#": {".cs"}, - "Ruby": {".rb"}, - "PHP": {".php"}, - "Swift": {".swift"}, - "Kotlin": {".kt"}, - "Rust": {".rs"}, - "Shell": {".sh", ".bash"}, - "Perl": {".pl", ".pm"}, - "Raku": {".raku", ".rakumod", ".p6", ".pm6"}, - "Haskell": {".hs"}, - "Lua": {".lua"}, - "HTML": {".html", ".htm"}, - "CSS": {".css"}, - "SQL": {".sql"}, - "Make": {"Makefile", "makefile", "GNUmakefile"}, - "HCL": {".tf", ".tfvars", ".hcl"}, - "AWK": {".awk", ".cgi"}, // .cgi files can be AWK scripts + "Go": {".go"}, + "Python": {".py"}, + "JavaScript": {".js"}, + "TypeScript": {".ts"}, + "Java": {".java"}, + "C": {".c", ".h"}, + "C++": {".cpp", ".cc", ".cxx", ".hpp"}, + "C/C++": {".h"}, + "C#": {".cs"}, + "Ruby": {".rb"}, + "PHP": {".php"}, + "Swift": {".swift"}, + "Kotlin": {".kt"}, + "Rust": {".rs"}, + "Shell": {".sh", ".bash"}, + "Perl": {".pl", ".pm"}, + "Raku": {".raku", ".rakumod", ".p6", ".pm6"}, + "Haskell": {".hs"}, + "Lua": {".lua"}, + "HTML": {".html", ".htm"}, + "CSS": {".css"}, + "SQL": {".sql"}, + "Make": {"Makefile", "makefile", "GNUmakefile"}, + "HCL": {".tf", ".tfvars", ".hcl"}, + "AWK": {".awk", ".cgi"}, // .cgi files can be AWK scripts } // Get file extensions for the primary language @@ -79,13 +79,13 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str if info.IsDir() { name := info.Name() // Skip hidden directories and common non-code directories - if strings.HasPrefix(name, ".") && name != "." || - name == "node_modules" || - name == "vendor" || - name == "target" || - name == "dist" || - name == "build" || - name == "__pycache__" { + if strings.HasPrefix(name, ".") && name != "." || + name == "node_modules" || + name == "vendor" || + name == "target" || + name == "dist" || + name == "build" || + name == "__pycache__" { return filepath.SkipDir } return nil @@ -99,7 +99,7 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str // Check if file matches extensions basename := filepath.Base(path) ext := filepath.Ext(path) - + matched := false for _, validExt := range extensions { if validExt == basename || (strings.HasPrefix(validExt, ".") && ext == validExt) { @@ -107,7 +107,7 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str break } } - + // For executable files, also check shebang if primary language is AWK and file has .cgi extension if !matched && primaryLang == "AWK" && ext == ".cgi" && info.Mode()&0111 != 0 { if file, err := os.Open(path); err == nil { @@ -121,14 +121,14 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str file.Close() } } - + if matched { // Skip test files and generated files - if !strings.Contains(basename, "_test") && - !strings.Contains(basename, ".test.") && - !strings.Contains(basename, ".min.") && - !strings.Contains(path, "/test/") && - !strings.Contains(path, "/tests/") { + if !strings.Contains(basename, "_test") && + !strings.Contains(basename, ".test.") && + !strings.Contains(basename, ".min.") && + !strings.Contains(path, "/test/") && + !strings.Contains(path, "/tests/") { codeFiles = append(codeFiles, path) } } @@ -148,10 +148,10 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str rand.Shuffle(len(codeFiles), func(i, j int) { codeFiles[i], codeFiles[j] = codeFiles[j], codeFiles[i] }) - + var snippet string var selectedFile string - + // Try up to 5 files to find a good snippet for i := 0; i < len(codeFiles) && i < 5; i++ { candidateFile := codeFiles[i] @@ -159,28 +159,28 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str if err != nil { continue } - + // Check if this snippet has acceptable line lengths if hasAcceptableLineLength(candidateSnippet, 80) { snippet = candidateSnippet selectedFile = candidateFile break } - + // Keep the first valid snippet as fallback if snippet == "" { snippet = candidateSnippet selectedFile = candidateFile } } - + if snippet == "" { return "", "", fmt.Errorf("no valid code snippets found") } // Get relative path for display relPath, _ := filepath.Rel(repoPath, selectedFile) - + return snippet, fmt.Sprintf("%s from `%s`", primaryLang, relPath), nil } @@ -236,9 +236,9 @@ func extractSnippetFromFile(filePath string, minLines, maxLines int) (string, er skipLines := 0 for i, line := range lines { trimmed := strings.TrimSpace(line) - if trimmed != "" && !strings.HasPrefix(trimmed, "import") && - !strings.HasPrefix(trimmed, "package") && !strings.HasPrefix(trimmed, "using") && - !strings.HasPrefix(trimmed, "#include") && !strings.HasPrefix(trimmed, "from") { + if trimmed != "" && !strings.HasPrefix(trimmed, "import") && + !strings.HasPrefix(trimmed, "package") && !strings.HasPrefix(trimmed, "using") && + !strings.HasPrefix(trimmed, "#include") && !strings.HasPrefix(trimmed, "from") { skipLines = i break } @@ -260,19 +260,19 @@ func findSmallestCompleteFunction(lines []string) string { end int size int } - + var functions []functionInfo - + // Keywords that typically start functions/methods functionKeywords := []string{ "func ", "function ", "def ", "public ", "private ", "protected ", "static ", "async ", "procedure ", "sub ", "method ", } - + // Find all complete functions for i := 0; i < len(lines); i++ { line := strings.TrimSpace(lines[i]) - + // Check if this line starts a function isFunction := false for _, keyword := range functionKeywords { @@ -281,11 +281,11 @@ func findSmallestCompleteFunction(lines []string) string { break } } - + if !isFunction { continue } - + // Try to find the end of this function functionEnd := findFunctionEnd(lines, i) if functionEnd > i { @@ -300,7 +300,7 @@ func findSmallestCompleteFunction(lines []string) string { } } } - + // Find the smallest function with acceptable line lengths if len(functions) > 0 { // First try to find a function with all lines <= 80 chars @@ -310,7 +310,7 @@ func findSmallestCompleteFunction(lines []string) string { return snippet } } - + // If none found, return the smallest function (will be broken later) smallest := functions[0] for _, f := range functions[1:] { @@ -320,7 +320,7 @@ func findSmallestCompleteFunction(lines []string) string { } return strings.Join(lines[smallest.start:smallest.end+1], "\n") } - + return "" } @@ -329,11 +329,11 @@ func findFunctionEnd(lines []string, start int) int { if start >= len(lines) { return -1 } - + // For brace-based languages braceCount := 0 inFunction := false - + // For Python - track initial indentation isPython := strings.Contains(lines[start], "def ") || strings.Contains(lines[start], "class ") var initialIndent int @@ -346,11 +346,11 @@ func findFunctionEnd(lines []string, start int) int { } } } - + for i := start; i < len(lines); i++ { line := lines[i] trimmed := strings.TrimSpace(line) - + // Handle Python indentation if isPython && i > start { if trimmed == "" { @@ -361,7 +361,7 @@ func findFunctionEnd(lines []string, start int) int { return i - 1 } } - + // Handle brace-based languages for _, ch := range line { if ch == '{' { @@ -375,12 +375,12 @@ func findFunctionEnd(lines []string, start int) int { } } } - + // If we're in Python and reached the end, return the last line if isPython { return len(lines) - 1 } - + return -1 } @@ -391,11 +391,11 @@ func findCompleteFunctionOrMethod(lines []string, minLines, maxLines int) (int, "func ", "function ", "def ", "public ", "private ", "protected ", "static ", "async ", "procedure ", "sub ", "method ", } - + // Try to find a function that fits within our size constraints for i := 0; i < len(lines); i++ { line := strings.TrimSpace(lines[i]) - + // Check if this line starts a function isFunction := false for _, keyword := range functionKeywords { @@ -404,11 +404,11 @@ func findCompleteFunctionOrMethod(lines []string, minLines, maxLines int) (int, break } } - + if !isFunction { continue } - + // Try to find the end of this function functionEnd := findFunctionEnd(lines, i) if functionEnd > i { @@ -418,7 +418,7 @@ func findCompleteFunctionOrMethod(lines []string, minLines, maxLines int) (int, } } } - + return -1, -1 } @@ -435,7 +435,7 @@ func findInterestingStart(lines []string, snippetSize int) int { line := strings.TrimSpace(lines[i]) // Skip empty lines and comments if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") || - strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") { + strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") { continue } @@ -457,10 +457,10 @@ func stripComments(code string) string { lines := strings.Split(code, "\n") var result []string inMultilineComment := false - + for _, line := range lines { trimmed := strings.TrimSpace(line) - + // Handle multi-line comments for C-style languages if strings.Contains(line, "/*") { inMultilineComment = true @@ -475,19 +475,19 @@ func stripComments(code string) string { continue } } - + if inMultilineComment { if strings.Contains(line, "*/") { inMultilineComment = false } continue } - + // Skip single-line comments if trimmed == "" { // Keep empty lines for readability result = append(result, line) - } else if strings.HasPrefix(trimmed, "//") || + } else if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "#include") && !strings.HasPrefix(trimmed, "#define") || strings.HasPrefix(trimmed, "<!--") || strings.HasPrefix(trimmed, "*") && len(trimmed) > 1 && trimmed[1] == ' ' { @@ -509,7 +509,7 @@ func stripComments(code string) string { result = append(result, line) } } - + // Remove leading and trailing empty lines for len(result) > 0 && strings.TrimSpace(result[0]) == "" { result = result[1:] @@ -517,13 +517,13 @@ func stripComments(code string) string { for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" { result = result[:len(result)-1] } - + // Remove unnecessary indentation result = removeCommonIndentation(result) - + // Break long lines result = breakLongLines(result, 80) - + return strings.Join(result, "\n") } @@ -532,11 +532,11 @@ func removeCommonIndentation(lines []string) []string { if len(lines) == 0 { return lines } - + // Find the common prefix of whitespace var commonPrefix string firstNonEmpty := -1 - + // Find first non-empty line to use as reference for i, line := range lines { if strings.TrimSpace(line) != "" { @@ -544,11 +544,11 @@ func removeCommonIndentation(lines []string) []string { break } } - + if firstNonEmpty == -1 { return lines } - + // Get the whitespace prefix of the first non-empty line firstLine := lines[firstNonEmpty] for i, ch := range firstLine { @@ -557,18 +557,18 @@ func removeCommonIndentation(lines []string) []string { break } } - + // If the first line has no indentation, return as-is if commonPrefix == "" { return lines } - + // Find the actual common prefix among all non-empty lines for _, line := range lines { if strings.TrimSpace(line) == "" { continue } - + // Reduce commonPrefix to what this line shares for i := 0; i < len(commonPrefix); i++ { if i >= len(line) || line[i] != commonPrefix[i] { @@ -576,17 +576,17 @@ func removeCommonIndentation(lines []string) []string { break } } - + if commonPrefix == "" { break } } - + // If no common prefix found, return as-is if commonPrefix == "" { return lines } - + // Remove common prefix from all lines result := make([]string, len(lines)) prefixLen := len(commonPrefix) @@ -599,7 +599,7 @@ func removeCommonIndentation(lines []string) []string { result[i] = line } } - + return result } @@ -617,18 +617,18 @@ func hasAcceptableLineLength(snippet string, maxLength int) bool { // breakLongLines breaks lines that exceed maxLength at appropriate points func breakLongLines(lines []string, maxLength int) []string { var result []string - + for _, line := range lines { if len(line) <= maxLength { result = append(result, line) continue } - + // Try to break the line intelligently broken := breakLine(line, maxLength) result = append(result, broken...) } - + return result } @@ -638,7 +638,7 @@ func breakLine(line string, maxLength int) []string { if len(line) <= maxLength { return []string{line} } - + // Get the indentation of the original line indent := "" for _, ch := range line { @@ -648,43 +648,43 @@ func breakLine(line string, maxLength int) []string { break } } - + // Common break points in order of preference breakPoints := []string{ - ", ", // After comma - " && ", // Before logical operators + ", ", // After comma + " && ", // Before logical operators " || ", - " + ", // Before arithmetic operators + " + ", // Before arithmetic operators " - ", " * ", " / ", - " = ", // Before assignment + " = ", // Before assignment " := ", - " == ", // Before comparison + " == ", // Before comparison " != ", " < ", " > ", " <= ", " >= ", - "(", // After opening parenthesis - " ", // Any space + "(", // After opening parenthesis + " ", // Any space } - + var result []string remaining := line isFirstLine := true - + for len(remaining) > maxLength { // Find the best break point bestBreak := -1 - + for _, breakPoint := range breakPoints { // Look for break point before maxLength searchIn := remaining if len(searchIn) > maxLength { searchIn = remaining[:maxLength] } - + idx := strings.LastIndex(searchIn, breakPoint) if idx > 0 && idx < maxLength { // For some break points, we want to break after them @@ -696,12 +696,12 @@ func breakLine(line string, maxLength int) []string { } } } - + // If no good break point found, break at maxLength if bestBreak == -1 { bestBreak = maxLength } - + // Add the line lineToAdd := remaining[:bestBreak] if !isFirstLine && !strings.HasPrefix(strings.TrimSpace(lineToAdd), "//") { @@ -709,7 +709,7 @@ func breakLine(line string, maxLength int) []string { lineToAdd = indent + " " + strings.TrimLeft(lineToAdd, " \t") } result = append(result, strings.TrimRight(lineToAdd, " ")) - + // Update remaining remaining = remaining[bestBreak:] if !isFirstLine && !strings.HasPrefix(strings.TrimSpace(remaining), "//") { @@ -717,7 +717,7 @@ func breakLine(line string, maxLength int) []string { } isFirstLine = false } - + // Add the last part if len(remaining) > 0 { if !isFirstLine && !strings.HasPrefix(strings.TrimSpace(remaining), "//") { @@ -725,6 +725,6 @@ func breakLine(line string, maxLength int) []string { } result = append(result, remaining) } - + return result -}
\ No newline at end of file +} diff --git a/internal/showcase/images.go b/internal/showcase/images.go index 66372cb..b6fe6d7 100644 --- a/internal/showcase/images.go +++ b/internal/showcase/images.go @@ -15,7 +15,7 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er // Look for README files readmeFiles := []string{"README.md", "readme.md", "Readme.md", "README.MD"} var readmePath string - + for _, filename := range readmeFiles { path := filepath.Join(repoPath, filename) if _, err := os.Stat(path); err == nil { @@ -23,30 +23,30 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er break } } - + if readmePath == "" { return nil, nil // No README found, not an error } - + // Read README content content, err := os.ReadFile(readmePath) if err != nil { return nil, fmt.Errorf("failed to read README: %w", err) } - + fmt.Printf("Found README at: %s\n", readmePath) - + // Extract image references images := extractImageReferences(string(content)) fmt.Printf("Found %d images in README\n", len(images)) for i, img := range images { fmt.Printf(" Image %d: %s\n", i+1, img) } - + if len(images) == 0 { return nil, nil } - + // Limit to first and last image (max 2) var selectedImages []string if len(images) == 1 { @@ -54,19 +54,19 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er } else { selectedImages = []string{images[0], images[len(images)-1]} } - + // Create showcase subdirectory for this repo repoShowcaseDir := filepath.Join(showcaseDir, "showcase", repoName) if err := os.MkdirAll(repoShowcaseDir, 0755); err != nil { return nil, fmt.Errorf("failed to create showcase directory: %w", err) } - + // Copy images and collect relative paths var copiedImages []string for i, imgPath := range selectedImages { var destFilename string var err error - + if strings.HasPrefix(imgPath, "http://") || strings.HasPrefix(imgPath, "https://") { // Handle URL - download the image // Extract extension from URL, handling query parameters @@ -78,7 +78,7 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er } destFilename = fmt.Sprintf("image-%d%s", i+1, ext) destPath := filepath.Join(repoShowcaseDir, destFilename) - + if err = downloadImage(imgPath, destPath); err != nil { fmt.Printf("Warning: Failed to download image %s: %v\n", imgPath, err) continue @@ -89,31 +89,31 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er if !filepath.IsAbs(imgPath) { srcPath = filepath.Join(repoPath, imgPath) } - + // Check if image exists if _, err := os.Stat(srcPath); err != nil { fmt.Printf("Warning: Image not found: %s\n", srcPath) continue } - + // Generate destination filename ext := filepath.Ext(srcPath) destFilename = fmt.Sprintf("image-%d%s", i+1, ext) destPath := filepath.Join(repoShowcaseDir, destFilename) - + // Copy image if err := copyFile(srcPath, destPath); err != nil { fmt.Printf("Warning: Failed to copy image %s: %v\n", srcPath, err) continue } } - + // Store relative path from showcase directory relativePath := filepath.Join("showcase", repoName, destFilename) copiedImages = append(copiedImages, relativePath) fmt.Printf("Copied/Downloaded image: %s -> %s\n", imgPath, relativePath) } - + return copiedImages, nil } @@ -121,24 +121,24 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er func extractImageReferences(content string) []string { var images []string seen := make(map[string]bool) - + // Regex patterns for markdown images patterns := []string{ - `!\[([^\]]*)\]\(([^)]+)\)`, //  - `<img[^>]+src=["']([^"']+)["'][^>]*>`, // <img src="url"> with quotes - `<img[^>]+src=([^\s>]+)[^>]*>`, // <img src=url> without quotes - `!\[([^\]]*)\]\[([^\]]+)\]`, // ![alt][ref] - `\[([^\]]+)\]:\s*(.+?)(?:\s+"[^"]+")?\s*$`, // [ref]: url "title" + `!\[([^\]]*)\]\(([^)]+)\)`, //  + `<img[^>]+src=["']([^"']+)["'][^>]*>`, // <img src="url"> with quotes + `<img[^>]+src=([^\s>]+)[^>]*>`, // <img src=url> without quotes + `!\[([^\]]*)\]\[([^\]]+)\]`, // ![alt][ref] + `\[([^\]]+)\]:\s*(.+?)(?:\s+"[^"]+")?\s*$`, // [ref]: url "title" } - + fmt.Printf("DEBUG: Content length: %d bytes\n", len(content)) - + // Extract from markdown image syntax for i, pattern := range patterns[:3] { // First three patterns have URLs in different positions re := regexp.MustCompile(pattern) matches := re.FindAllStringSubmatch(content, -1) fmt.Printf("DEBUG: Pattern %d (%s) found %d matches\n", i, pattern, len(matches)) - + for _, match := range matches { var url string if pattern == patterns[0] { @@ -146,18 +146,18 @@ func extractImageReferences(content string) []string { } else { url = match[1] // For <img src="url"> (both with and without quotes) } - + // Clean and validate URL url = strings.TrimSpace(url) - + // Handle markdown image titles - remove anything after a space or quote if idx := strings.IndexAny(url, " \"'"); idx != -1 { url = url[:idx] } url = strings.TrimSpace(url) - + fmt.Printf("DEBUG: Found potential image URL: %s\n", url) - + if isImageFile(url) { fmt.Printf("DEBUG: URL is image file\n") if !seen[url] { @@ -181,7 +181,7 @@ func extractImageReferences(content string) []string { } } } - + // Handle reference-style images refPattern := regexp.MustCompile(patterns[4]) refMatches := refPattern.FindAllStringSubmatch(content, -1) @@ -189,7 +189,7 @@ func extractImageReferences(content string) []string { for _, match := range refMatches { refs[match[1]] = strings.TrimSpace(match[2]) } - + // Find reference-style image uses refUsePattern := regexp.MustCompile(patterns[3]) refUseMatches := refUsePattern.FindAllStringSubmatch(content, -1) @@ -202,7 +202,7 @@ func extractImageReferences(content string) []string { } } } - + return images } @@ -220,7 +220,7 @@ func isImageFile(url string) bool { // isGitHostedImage checks if URL is from GitHub/Codeberg func isGitHostedImage(url string) bool { - return strings.Contains(url, "github.com") || + return strings.Contains(url, "github.com") || strings.Contains(url, "githubusercontent.com") || strings.Contains(url, "codeberg.org") || strings.Contains(url, "codeberg.page") @@ -233,18 +233,18 @@ func copyFile(src, dst string) error { return err } defer sourceFile.Close() - + destFile, err := os.Create(dst) if err != nil { return err } defer destFile.Close() - + _, err = io.Copy(destFile, sourceFile) if err != nil { return err } - + return destFile.Sync() } @@ -256,11 +256,11 @@ func downloadImage(url, dst string) error { if err != nil { return fmt.Errorf("curl failed: %v, output: %s", err, string(output)) } - + // Verify the file was created if _, err := os.Stat(dst); err != nil { return fmt.Errorf("downloaded file not found: %v", err) } - + return nil -}
\ No newline at end of file +} diff --git a/internal/showcase/language_detector.go b/internal/showcase/language_detector.go index 0f356b8..692f048 100644 --- a/internal/showcase/language_detector.go +++ b/internal/showcase/language_detector.go @@ -14,111 +14,111 @@ import ( func detectLanguages(repoPath string) (languages []LanguageStats, documentation []LanguageStats, err error) { languageLines := make(map[string]int) documentationLines := make(map[string]int) - + // Define common language extensions langExtensions := map[string]string{ - ".go": "Go", - ".py": "Python", - ".js": "JavaScript", - ".ts": "TypeScript", - ".java": "Java", - ".c": "C", - ".cpp": "C++", - ".cc": "C++", - ".cxx": "C++", - ".h": "C/C++", - ".hpp": "C++", - ".hxx": "C++", - ".cs": "C#", - ".rb": "Ruby", - ".php": "PHP", - ".swift": "Swift", - ".kt": "Kotlin", - ".rs": "Rust", - ".scala": "Scala", - ".r": "R", - ".m": "Objective-C", - ".mm": "Objective-C++", - ".sh": "Shell", - ".bash": "Shell", - ".zsh": "Shell", - ".fish": "Shell", - ".pl": "Perl", - ".pm": "Perl", - ".raku": "Raku", - ".rakumod": "Raku", - ".rakudoc": "Raku", + ".go": "Go", + ".py": "Python", + ".js": "JavaScript", + ".ts": "TypeScript", + ".java": "Java", + ".c": "C", + ".cpp": "C++", + ".cc": "C++", + ".cxx": "C++", + ".h": "C/C++", + ".hpp": "C++", + ".hxx": "C++", + ".cs": "C#", + ".rb": "Ruby", + ".php": "PHP", + ".swift": "Swift", + ".kt": "Kotlin", + ".rs": "Rust", + ".scala": "Scala", + ".r": "R", + ".m": "Objective-C", + ".mm": "Objective-C++", + ".sh": "Shell", + ".bash": "Shell", + ".zsh": "Shell", + ".fish": "Shell", + ".pl": "Perl", + ".pm": "Perl", + ".raku": "Raku", + ".rakumod": "Raku", + ".rakudoc": "Raku", ".rakutest": "Raku", - ".p6": "Raku", - ".pm6": "Raku", - ".lua": "Lua", - ".vim": "Vim Script", - ".el": "Emacs Lisp", - ".clj": "Clojure", - ".hs": "Haskell", - ".ml": "OCaml", - ".ex": "Elixir", - ".exs": "Elixir", - ".dart": "Dart", - ".jl": "Julia", - ".nim": "Nim", - ".v": "V", - ".zig": "Zig", - ".html": "HTML", - ".htm": "HTML", - ".css": "CSS", - ".scss": "SCSS", - ".sass": "Sass", - ".less": "Less", - ".xml": "XML", - ".json": "JSON", - ".yaml": "YAML", - ".yml": "YAML", - ".toml": "TOML", - ".ini": "INI", - ".cfg": "Config", - ".conf": "Config", - ".sql": "SQL", - ".tf": "HCL", - ".tfvars": "HCL", - ".hcl": "HCL", - ".awk": "AWK", + ".p6": "Raku", + ".pm6": "Raku", + ".lua": "Lua", + ".vim": "Vim Script", + ".el": "Emacs Lisp", + ".clj": "Clojure", + ".hs": "Haskell", + ".ml": "OCaml", + ".ex": "Elixir", + ".exs": "Elixir", + ".dart": "Dart", + ".jl": "Julia", + ".nim": "Nim", + ".v": "V", + ".zig": "Zig", + ".html": "HTML", + ".htm": "HTML", + ".css": "CSS", + ".scss": "SCSS", + ".sass": "Sass", + ".less": "Less", + ".xml": "XML", + ".json": "JSON", + ".yaml": "YAML", + ".yml": "YAML", + ".toml": "TOML", + ".ini": "INI", + ".cfg": "Config", + ".conf": "Config", + ".sql": "SQL", + ".tf": "HCL", + ".tfvars": "HCL", + ".hcl": "HCL", + ".awk": "AWK", } - + // Define documentation/text extensions docExtensions := map[string]string{ - ".md": "Markdown", - ".rst": "reStructuredText", - ".tex": "LaTeX", - ".txt": "Text", - ".adoc": "AsciiDoc", - ".org": "Org", + ".md": "Markdown", + ".rst": "reStructuredText", + ".tex": "LaTeX", + ".txt": "Text", + ".adoc": "AsciiDoc", + ".org": "Org", } // Special files that indicate specific languages specialFiles := map[string]string{ - "makefile": "Make", - "gnumakefile": "Make", - "dockerfile": "Docker", - "dockerfile.*": "Docker", - "cmakelists.txt": "CMake", - "rakefile": "Ruby", - "gemfile": "Ruby", - "package.json": "JavaScript", - "cargo.toml": "Rust", - "go.mod": "Go", - "go.sum": "Go", - "pom.xml": "Java", - "build.gradle": "Gradle", - "build.gradle.kts": "Kotlin", - "requirements.txt": "Python", - "setup.py": "Python", - "pyproject.toml": "Python", - "composer.json": "PHP", - "*.dockerfile": "Docker", - "containerfile": "Docker", - "jenkinsfile": "Groovy", - "vagrantfile": "Ruby", + "makefile": "Make", + "gnumakefile": "Make", + "dockerfile": "Docker", + "dockerfile.*": "Docker", + "cmakelists.txt": "CMake", + "rakefile": "Ruby", + "gemfile": "Ruby", + "package.json": "JavaScript", + "cargo.toml": "Rust", + "go.mod": "Go", + "go.sum": "Go", + "pom.xml": "Java", + "build.gradle": "Gradle", + "build.gradle.kts": "Kotlin", + "requirements.txt": "Python", + "setup.py": "Python", + "pyproject.toml": "Python", + "composer.json": "PHP", + "*.dockerfile": "Docker", + "containerfile": "Docker", + "jenkinsfile": "Groovy", + "vagrantfile": "Ruby", } // Count lines for each language @@ -131,15 +131,15 @@ func detectLanguages(repoPath string) (languages []LanguageStats, documentation if info.IsDir() { name := info.Name() // Skip hidden directories and common non-code directories - if strings.HasPrefix(name, ".") && name != "." || - name == "node_modules" || - name == "vendor" || - name == "target" || - name == "dist" || - name == "build" || - name == "out" || - name == "__pycache__" || - name == "coverage" { + if strings.HasPrefix(name, ".") && name != "." || + name == "node_modules" || + name == "vendor" || + name == "target" || + name == "dist" || + name == "build" || + name == "out" || + name == "__pycache__" || + name == "coverage" { return filepath.SkipDir } return nil @@ -157,7 +157,7 @@ func detectLanguages(repoPath string) (languages []LanguageStats, documentation // Determine the language or documentation type var language string var isDoc bool - + // Check special files first if lang, ok := specialFiles[basename]; ok { language = lang @@ -204,7 +204,7 @@ func detectLanguages(repoPath string) (languages []LanguageStats, documentation file.Close() } } - + // If we identified a language, count its lines if language != "" { lines, err := countFileLines(path) @@ -317,4 +317,4 @@ func FormatLanguagesWithPercentages(languages []LanguageStats) string { } return strings.Join(parts, ", ") -}
\ No newline at end of file +} diff --git a/internal/showcase/metadata.go b/internal/showcase/metadata.go index ca8af05..147e714 100644 --- a/internal/showcase/metadata.go +++ b/internal/showcase/metadata.go @@ -22,8 +22,8 @@ type RepoMetadata struct { Languages []LanguageStats // Programming languages with usage statistics Documentation []LanguageStats // Documentation/text files with usage statistics CommitCount int - LinesOfCode int // Lines of code (excluding documentation) - LinesOfDocs int // Lines of documentation + LinesOfCode int // Lines of code (excluding documentation) + LinesOfDocs int // Lines of documentation FirstCommitDate string LastCommitDate string License string @@ -58,7 +58,7 @@ func extractRepoMetadata(repoPath string) (*RepoMetadata, error) { loc += lang.Lines } metadata.LinesOfCode = loc - + locDocs := 0 for _, doc := range metadata.Documentation { locDocs += doc.Lines @@ -101,7 +101,6 @@ func extractRepoMetadata(repoPath string) (*RepoMetadata, error) { return metadata, nil } - // getCommitCount returns the total number of commits func getCommitCount(repoPath string) (int, error) { cmd := exec.Command("git", "-C", repoPath, "rev-list", "--all", "--count") @@ -126,7 +125,7 @@ func countLinesOfCode(repoPath string) (int, error) { `cd "%s" && git ls-files | grep -E '\.(go|py|js|ts|java|c|cpp|h|hpp|cs|rb|php|swift|kt|rs|scala|r|sh|bash|zsh|pl|lua|vim|el|clj|hs|ml|ex|exs|dart|jl|nim|v|zig|html|css|scss|sass|json|xml|yaml|yml|toml|ini|conf|cfg)$' | xargs wc -l 2>/dev/null | tail -n 1 | awk '{print $1}'`, repoPath, )) - + output, err := cmd.Output() if err != nil { // Fallback: try a simpler approach @@ -264,12 +263,12 @@ func getAverageCommitAge(repoPath string, commitCount int) (float64, error) { if line == "" { continue } - + timestamp, err := strconv.ParseInt(line, 10, 64) if err != nil { continue } - + age := (now - float64(timestamp)) / 86400 // Convert to days totalAge += age validCommits++ @@ -310,12 +309,12 @@ func getLatestTag(repoPath string) (string, string, bool, error) { break } } - + if latestTag == "" { // No version-like tags found return "", "", false, nil } - + // Get the date of the latest tag cmd = exec.Command("git", "-C", repoPath, "log", "-1", "--format=%ai", latestTag) dateOutput, err := cmd.Output() @@ -323,7 +322,7 @@ func getLatestTag(repoPath string) (string, string, bool, error) { // Tag exists but couldn't get date return latestTag, "", true, nil } - + // Extract just the date part (YYYY-MM-DD) parts := strings.Fields(string(dateOutput)) tagDate := "" @@ -339,24 +338,24 @@ func getLatestTag(repoPath string) (string, string, bool, error) { func isVersionTag(tag string) bool { // Remove 'v' prefix if present versionStr := strings.TrimPrefix(tag, "v") - + // Check if the remaining string contains at least one digit and one dot hasDigit := false hasDot := false - + for _, ch := range versionStr { if ch >= '0' && ch <= '9' { hasDigit = true } else if ch == '.' { hasDot = true - } else if ch != '-' && ch != '+' && ch != '_' && - (ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') { + } else if ch != '-' && ch != '+' && ch != '_' && + (ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') { // Allow alphanumeric characters and common separators // but anything else makes it not a version return false } } - + // Must have at least one digit, and either: // - have a dot (e.g., 1.0, 0.1.2) // - be just digits (e.g., 2, 2024) @@ -367,6 +366,6 @@ func isVersionTag(tag string) bool { return true } } - + return hasDigit && hasDot -}
\ No newline at end of file +} diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go index 01e0e2b..1c057f3 100644 --- a/internal/showcase/showcase.go +++ b/internal/showcase/showcase.go @@ -1,8 +1,8 @@ package showcase import ( - "context" - "encoding/json" + "context" + "encoding/json" "fmt" "os" "os/exec" @@ -51,7 +51,7 @@ func New(cfg *config.Config, workDir string) *Generator { return &Generator{ config: cfg, workDir: workDir, - aiTool: "claude", // default to claude + aiTool: "amp", // default to amp } } @@ -157,25 +157,25 @@ func (g *Generator) GenerateShowcase(repoFilter []string, forceRegenerate bool) // runCommandWithTimeout runs a command with a short timeout and returns trimmed stdout. // Stderr is included in the error message for easier debugging when GITSYNCER_DEBUG=1. func runCommandWithTimeout(name string, args ...string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) - defer cancel() - cmd := exec.CommandContext(ctx, name, args...) - out, err := cmd.CombinedOutput() - if ctx.Err() == context.DeadlineExceeded { - return "", fmt.Errorf("command timed out") - } - if err != nil { - // include a snippet of output for debugging - msg := strings.TrimSpace(string(out)) - if len(msg) > 300 { - msg = msg[:300] + "..." - } - if msg != "" { - return "", fmt.Errorf("%v: %s", err, msg) - } - return "", err - } - return string(out), nil + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("command timed out") + } + if err != nil { + // include a snippet of output for debugging + msg := strings.TrimSpace(string(out)) + if len(msg) > 300 { + msg = msg[:300] + "..." + } + if msg != "" { + return "", fmt.Errorf("%v: %s", err, msg) + } + return "", err + } + return string(out), nil } // getRepositories returns a list of repository directories in the work directory @@ -222,35 +222,48 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool } } - // Determine which AI tool to use (only if we need to run it) - // Prefer hexai if available when default tool is "claude" (aligns with release flow) - selectedTool := g.aiTool - if !haveCachedSummary { - switch g.aiTool { - case "claude", "claude-code", "": - // Try hexai -> claude -> aichat - if _, err := exec.LookPath("hexai"); err == nil { - selectedTool = "hexai" - } else if _, err := exec.LookPath("claude"); err == nil { - selectedTool = "claude" - } else if _, err := exec.LookPath("aichat"); err == nil { - selectedTool = "aichat" - } else { - // No AI tool available; fall back to README-based summary later - selectedTool = "" - } - case "hexai", "aichat": - if _, err := exec.LookPath(g.aiTool); err != nil { - // Requested tool missing; fall back to README-based summary later - selectedTool = "" - } else { - selectedTool = g.aiTool - } - default: - // Unsupported tool configured; fall back to README-based summary later - selectedTool = "" - } - } + // Determine which AI tool to use (only if we need to run it) + // Prefer amp if available when default tool is "" (aligns with release flow) + selectedTool := g.aiTool + if !haveCachedSummary { + switch g.aiTool { + case "amp", "": + // Try amp -> hexai -> claude -> aichat + if _, err := exec.LookPath("amp"); err == nil { + selectedTool = "amp" + } else if _, err := exec.LookPath("hexai"); err == nil { + selectedTool = "hexai" + } else if _, err := exec.LookPath("claude"); err == nil { + selectedTool = "claude" + } else if _, err := exec.LookPath("aichat"); err == nil { + selectedTool = "aichat" + } else { + // No AI tool available; fall back to README-based summary later + selectedTool = "" + } + case "claude", "claude-code": + // Try claude -> hexai -> aichat + if _, err := exec.LookPath("claude"); err == nil { + selectedTool = "claude" + } else if _, err := exec.LookPath("hexai"); err == nil { + selectedTool = "hexai" + } else if _, err := exec.LookPath("aichat"); err == nil { + selectedTool = "aichat" + } else { + selectedTool = "" + } + case "hexai", "aichat": + if _, err := exec.LookPath(g.aiTool); err != nil { + // Requested tool missing; fall back to README-based summary later + selectedTool = "" + } else { + selectedTool = g.aiTool + } + default: + // Unsupported tool configured; fall back to README-based summary later + selectedTool = "" + } + } // Change to repository directory originalDir, err := os.Getwd() @@ -273,50 +286,78 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool // Get the summary - either from cache or by running AI tool var summary string - if haveCachedSummary { - summary = cachedSummary - fmt.Printf("Using cached AI summary\n") - } else { - prompt := "Please provide a 1-2 paragraph summary of this project, explaining what it does, why it's useful, and how it's implemented. Focus on the key features and architecture. Be concise but informative." - - var cmd *exec.Cmd - - switch selectedTool { - case "claude": - fmt.Printf("Running Claude command:\n") - fmt.Printf(" claude --model sonnet \"%s\"\n", prompt) - cmd = exec.Command("claude", "--model", "sonnet", prompt) - case "hexai": - // Use README content as stdin and pass the prompt as argument - fmt.Printf("Running hexai command (stdin payload)\n") - // Find README file - readmeFiles := []string{ - "README.md", "readme.md", "Readme.md", - "README.MD", "README.txt", "readme.txt", - "README", "readme", - } - var readmeContent []byte - var readmeFound bool - for _, readmeFile := range readmeFiles { - content, err := os.ReadFile(readmeFile) - if err == nil { - readmeContent = content - readmeFound = true - fmt.Printf(" Using %s as input\n", readmeFile) - break - } - } - if readmeFound { - fmt.Printf(" echo <README content> | hexai \"%s\"\n", prompt) - cmd = exec.Command("hexai", prompt) - cmd.Stdin = strings.NewReader(string(readmeContent)) - } else { - // Will fall back below - cmd = nil - } - case "aichat": - // For aichat, we need to read README.md and pipe it to aichat - fmt.Printf("Running aichat command:\n") + if haveCachedSummary { + summary = cachedSummary + fmt.Printf("Using cached AI summary\n") + } else { + prompt := "Please provide a 1-2 paragraph summary of this project, explaining what it does, why it's useful, and how it's implemented. Focus on the key features and architecture. Be concise but informative." + + var cmd *exec.Cmd + + switch selectedTool { + case "amp": + // Use README content as stdin and pass the prompt as --execute argument + fmt.Printf("Running amp command (stdin payload)\n") + // Find README file + readmeFiles := []string{ + "README.md", "readme.md", "Readme.md", + "README.MD", "README.txt", "readme.txt", + "README", "readme", + } + var readmeContent []byte + var readmeFound bool + for _, readmeFile := range readmeFiles { + content, err := os.ReadFile(readmeFile) + if err == nil { + readmeContent = content + readmeFound = true + fmt.Printf(" Using %s as input\n", readmeFile) + break + } + } + if readmeFound { + fmt.Printf(" echo <README content> | amp --execute \"%s\"\n", prompt) + cmd = exec.Command("amp", "--execute", prompt) + cmd.Stdin = strings.NewReader(string(readmeContent)) + } else { + // Will fall back below + cmd = nil + } + case "claude": + fmt.Printf("Running Claude command:\n") + fmt.Printf(" claude --model sonnet \"%s\"\n", prompt) + cmd = exec.Command("claude", "--model", "sonnet", prompt) + case "hexai": + // Use README content as stdin and pass the prompt as argument + fmt.Printf("Running hexai command (stdin payload)\n") + // Find README file + readmeFiles := []string{ + "README.md", "readme.md", "Readme.md", + "README.MD", "README.txt", "readme.txt", + "README", "readme", + } + var readmeContent []byte + var readmeFound bool + for _, readmeFile := range readmeFiles { + content, err := os.ReadFile(readmeFile) + if err == nil { + readmeContent = content + readmeFound = true + fmt.Printf(" Using %s as input\n", readmeFile) + break + } + } + if readmeFound { + fmt.Printf(" echo <README content> | hexai \"%s\"\n", prompt) + cmd = exec.Command("hexai", prompt) + cmd.Stdin = strings.NewReader(string(readmeContent)) + } else { + // Will fall back below + cmd = nil + } + case "aichat": + // For aichat, we need to read README.md and pipe it to aichat + fmt.Printf("Running aichat command:\n") // Find README file readmeFiles := []string{ @@ -337,46 +378,46 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool } } - if readmeFound { - fmt.Printf(" echo <README content> | aichat \"%s\"\n", prompt) - cmd = exec.Command("aichat", prompt) - cmd.Stdin = strings.NewReader(string(readmeContent)) - } else { - // Will fall back below - cmd = nil - } - default: - // No/unsupported tool; will fall back below - cmd = nil - } - - if cmd != nil { - if output, err := cmd.Output(); err == nil { - summary = strings.TrimSpace(string(output)) - } - } - - // Fallback: create a minimal summary from README if AI unavailable/failed - if summary == "" { - readmeFiles := []string{ - "README.md", "readme.md", "Readme.md", - "README.MD", "README.txt", "readme.txt", - "README", "readme", - } - for _, readmeFile := range readmeFiles { - if content, err := os.ReadFile(readmeFile); err == nil { - parts := strings.Split(strings.TrimSpace(string(content)), "\n\n") - if len(parts) > 0 { - summary = strings.TrimSpace(parts[0]) - break - } - } - } - if summary == "" { - summary = fmt.Sprintf("%s: source code repository.", repoName) - } - } - } + if readmeFound { + fmt.Printf(" echo <README content> | aichat \"%s\"\n", prompt) + cmd = exec.Command("aichat", prompt) + cmd.Stdin = strings.NewReader(string(readmeContent)) + } else { + // Will fall back below + cmd = nil + } + default: + // No/unsupported tool; will fall back below + cmd = nil + } + + if cmd != nil { + if output, err := cmd.Output(); err == nil { + summary = strings.TrimSpace(string(output)) + } + } + + // Fallback: create a minimal summary from README if AI unavailable/failed + if summary == "" { + readmeFiles := []string{ + "README.md", "readme.md", "Readme.md", + "README.MD", "README.txt", "readme.txt", + "README", "readme", + } + for _, readmeFile := range readmeFiles { + if content, err := os.ReadFile(readmeFile); err == nil { + parts := strings.Split(strings.TrimSpace(string(content)), "\n\n") + if len(parts) > 0 { + summary = strings.TrimSpace(parts[0]) + break + } + } + } + if summary == "" { + summary = fmt.Sprintf("%s: source code repository.", repoName) + } + } + } // Build URLs codebergURL := "" |
