summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-31 16:18:07 +0300
committerPaul Buetow <paul@buetow.org>2025-08-31 16:18:07 +0300
commit45e9f23077ee8270542370e0c2307aa9dbecf63a (patch)
tree45e45d5dea24445097b352dc418f0444cabb13d7
parente5cf30e8df255fe4d4d34db7fc076f26a2b84fee (diff)
some fixes
-rw-r--r--internal/cli/showcase_only_handler.go114
-rw-r--r--internal/cmd/showcase.go62
-rw-r--r--internal/showcase/showcase.go228
3 files changed, 287 insertions, 117 deletions
diff --git a/internal/cli/showcase_only_handler.go b/internal/cli/showcase_only_handler.go
index f777729..2055612 100644
--- a/internal/cli/showcase_only_handler.go
+++ b/internal/cli/showcase_only_handler.go
@@ -14,50 +14,74 @@ import (
// HandleShowcaseOnly handles showcase generation without syncing
// It will clone repositories if they don't exist locally, but won't sync changes
func HandleShowcaseOnly(cfg *config.Config, flags *Flags) int {
- // Get all repositories from all sources
- allRepos, err := getAllRepositories(cfg)
- if err != nil {
- log.Printf("ERROR: Failed to get repositories: %v\n", err)
- return 1
- }
-
- if len(allRepos) == 0 {
- fmt.Println("No repositories found")
- return 1
- }
-
- fmt.Printf("Found %d repositories total\n", len(allRepos))
-
- // Create a minimal syncer just for cloning
- syncer := sync.New(cfg, flags.WorkDir)
- syncer.SetBackupEnabled(false) // Never use backup in showcase-only mode
-
- // Ensure repositories are cloned (but not synced)
- fmt.Println("\nEnsuring repositories are cloned locally...")
- for _, repo := range allRepos {
- if err := syncer.EnsureRepositoryCloned(repo); err != nil {
- fmt.Printf("WARNING: Failed to clone %s: %v\n", repo, err)
- // Continue with other repos
- }
- }
-
- // Generate showcase for all repositories
- fmt.Println("\nGenerating showcase for all repositories...")
- generator := showcase.New(cfg, flags.WorkDir)
-
- // Set AI tool if specified
- if flags.AITool != "" {
- generator.SetAITool(flags.AITool)
- }
-
- // Pass empty filter to process all repos
- if err := generator.GenerateShowcase(nil, flags.Force); err != nil {
- log.Printf("ERROR: Failed to generate showcase: %v\n", err)
- return 1
- }
-
- fmt.Println("Showcase generation completed!")
- return 0
+ // If a specific repo is requested, only generate for that repo
+ if flags.SyncRepo != "" {
+ repo := flags.SyncRepo
+
+ // Ensure the repository is cloned
+ syncer := sync.New(cfg, flags.WorkDir)
+ syncer.SetBackupEnabled(false)
+ if err := syncer.EnsureRepositoryCloned(repo); err != nil {
+ fmt.Printf("ERROR: Failed to clone %s: %v\n", repo, err)
+ return 1
+ }
+
+ // Generate showcase for just this repository
+ fmt.Printf("\nGenerating showcase for repository: %s...\n", repo)
+ generator := showcase.New(cfg, flags.WorkDir)
+ if flags.AITool != "" {
+ generator.SetAITool(flags.AITool)
+ }
+ if err := generator.GenerateShowcase([]string{repo}, flags.Force); err != nil {
+ log.Printf("ERROR: Failed to generate showcase for %s: %v\n", repo, err)
+ return 1
+ }
+ fmt.Println("Showcase generation completed!")
+ return 0
+ }
+
+ // Otherwise, process all repositories
+ allRepos, err := getAllRepositories(cfg)
+ if err != nil {
+ log.Printf("ERROR: Failed to get repositories: %v\n", err)
+ return 1
+ }
+ if len(allRepos) == 0 {
+ fmt.Println("No repositories found")
+ return 1
+ }
+ fmt.Printf("Found %d repositories total\n", len(allRepos))
+
+ // Create a minimal syncer just for cloning
+ syncer := sync.New(cfg, flags.WorkDir)
+ syncer.SetBackupEnabled(false) // Never use backup in showcase-only mode
+
+ // Ensure repositories are cloned (but not synced)
+ fmt.Println("\nEnsuring repositories are cloned locally...")
+ for _, repo := range allRepos {
+ if err := syncer.EnsureRepositoryCloned(repo); err != nil {
+ fmt.Printf("WARNING: Failed to clone %s: %v\n", repo, err)
+ // Continue with other repos
+ }
+ }
+
+ // Generate showcase for all repositories
+ fmt.Println("\nGenerating showcase for all repositories...")
+ generator := showcase.New(cfg, flags.WorkDir)
+
+ // Set AI tool if specified
+ if flags.AITool != "" {
+ generator.SetAITool(flags.AITool)
+ }
+
+ // Pass empty filter to process all repos
+ if err := generator.GenerateShowcase(nil, flags.Force); err != nil {
+ log.Printf("ERROR: Failed to generate showcase: %v\n", err)
+ return 1
+ }
+
+ fmt.Println("Showcase generation completed!")
+ return 0
}
// getAllRepositories collects all unique repository names from all sources
@@ -114,4 +138,4 @@ func getAllRepositories(cfg *config.Config) ([]string, error) {
}
return allRepos, nil
-} \ No newline at end of file
+}
diff --git a/internal/cmd/showcase.go b/internal/cmd/showcase.go
index 7785d6f..ea5128c 100644
--- a/internal/cmd/showcase.go
+++ b/internal/cmd/showcase.go
@@ -9,19 +9,21 @@ import (
)
var (
- forceRegenerate bool
- outputPath string
- outputFormat string
- excludePattern string
- showcaseAITool string
+ forceRegenerate bool
+ outputPath string
+ outputFormat string
+ excludePattern string
+ showcaseAITool string
+ showcaseRepo string
)
var showcaseCmd = &cobra.Command{
Use: "showcase",
Short: "Generate AI-powered project showcase",
- Long: `Generate a comprehensive showcase of all your projects using AI.
-This feature creates a formatted document with project summaries, statistics,
-and code snippets. By default uses Claude, but can also use aichat.`,
+ Long: `Generate a comprehensive showcase of all your projects using AI.
+This feature creates a formatted document with project summaries, statistics,
+and code snippets. By default uses Claude, but will try hexai first if available,
+then codex (if installed), and can also use aichat.`,
Example: ` # Generate showcase with cached summaries
gitsyncer showcase
@@ -37,27 +39,31 @@ and code snippets. By default uses Claude, but can also use aichat.`,
# Exclude certain repositories
gitsyncer showcase --exclude "test-.*"
- # Use aichat instead of claude for AI summaries
- gitsyncer showcase --ai-tool aichat`,
- Run: func(cmd *cobra.Command, args []string) {
- flags := buildFlags()
- flags.Showcase = true
- flags.Force = forceRegenerate
- flags.AITool = showcaseAITool
-
- fmt.Println("Running showcase generation for all repositories...")
- exitCode := cli.HandleShowcaseOnly(cfg, flags)
- os.Exit(exitCode)
- },
+ # Use a specific AI tool
+ gitsyncer showcase --ai-tool hexai`,
+ Run: func(cmd *cobra.Command, args []string) {
+ flags := buildFlags()
+ flags.Showcase = true
+ flags.Force = forceRegenerate
+ flags.AITool = showcaseAITool
+ if showcaseRepo != "" {
+ flags.SyncRepo = showcaseRepo
+ }
+
+ fmt.Println("Running showcase generation for all repositories...")
+ exitCode := cli.HandleShowcaseOnly(cfg, flags)
+ os.Exit(exitCode)
+ },
}
func init() {
- rootCmd.AddCommand(showcaseCmd)
-
- // Showcase flags
- showcaseCmd.Flags().BoolVarP(&forceRegenerate, "force", "f", false, "force regeneration of cached summaries")
- showcaseCmd.Flags().StringVarP(&outputPath, "output", "o", "", "custom output path (default: ~/git/foo.zone-content/gemtext/about/showcase.gmi.tpl)")
- showcaseCmd.Flags().StringVar(&outputFormat, "format", "gemtext", "output format: gemtext, markdown, html")
- showcaseCmd.Flags().StringVar(&excludePattern, "exclude", "", "exclude repos matching pattern")
- showcaseCmd.Flags().StringVar(&showcaseAITool, "ai-tool", "claude", "AI tool to use for project summaries (claude or aichat)")
+ rootCmd.AddCommand(showcaseCmd)
+
+ // Showcase flags
+ showcaseCmd.Flags().BoolVarP(&forceRegenerate, "force", "f", false, "force regeneration of cached summaries")
+ showcaseCmd.Flags().StringVarP(&outputPath, "output", "o", "", "custom output path (default: ~/git/foo.zone-content/gemtext/about/showcase.gmi.tpl)")
+ showcaseCmd.Flags().StringVar(&outputFormat, "format", "gemtext", "output format: gemtext, markdown, html")
+ showcaseCmd.Flags().StringVar(&excludePattern, "exclude", "", "exclude repos matching pattern")
+ showcaseCmd.Flags().StringVar(&showcaseAITool, "ai-tool", "claude", "AI tool for summaries: hexai, claude, claude-code, codex, or aichat (default tries hexai→claude→codex→aichat)")
+ showcaseCmd.Flags().StringVar(&showcaseRepo, "repo", "", "only generate showcase for a single repository")
}
diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go
index 6f36f22..7203d5e 100644
--- a/internal/showcase/showcase.go
+++ b/internal/showcase/showcase.go
@@ -1,7 +1,8 @@
package showcase
import (
- "encoding/json"
+ "context"
+ "encoding/json"
"fmt"
"os"
"os/exec"
@@ -153,6 +154,30 @@ func (g *Generator) GenerateShowcase(repoFilter []string, forceRegenerate bool)
return nil
}
+// 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
+}
+
// getRepositories returns a list of repository directories in the work directory
func (g *Generator) getRepositories() ([]string, error) {
entries, err := os.ReadDir(g.workDir)
@@ -197,12 +222,37 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool
}
}
- // Check if AI tool command exists (only if we need to run it)
- if !haveCachedSummary {
- if _, err := exec.LookPath(g.aiTool); err != nil {
- return nil, fmt.Errorf("%s command not found. Please install %s CLI", g.aiTool, g.aiTool)
- }
- }
+ // 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("codex"); err == nil {
+ selectedTool = "codex"
+ } 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", "codex":
+ 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()
@@ -225,22 +275,50 @@ 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 g.aiTool {
- case "claude":
- fmt.Printf("Running Claude command:\n")
- fmt.Printf(" claude --model sonnet \"%s\"\n", prompt)
- cmd = exec.Command("claude", "--model", "sonnet", prompt)
- 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 "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{
@@ -261,27 +339,89 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool
}
}
- if !readmeFound {
- return nil, fmt.Errorf("no README file found for aichat input")
- }
-
- fmt.Printf(" echo <README content> | aichat \"%s\"\n", prompt)
- cmd = exec.Command("aichat", prompt)
- cmd.Stdin = strings.NewReader(string(readmeContent))
- default:
- return nil, fmt.Errorf("unsupported AI tool: %s", g.aiTool)
- }
-
- output, err := cmd.Output()
- if err != nil {
- return nil, fmt.Errorf("failed to run %s: %w", g.aiTool, err)
- }
-
- summary = strings.TrimSpace(string(output))
- if summary == "" {
- return nil, fmt.Errorf("received empty summary from %s", g.aiTool)
- }
- }
+ 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
+ }
+ case "codex":
+ // Run codex CLI from inside the repository directory and let it infer context
+ // Try several non-interactive variants with a timeout, then fall back to prompt+stdin
+ fmt.Printf("Running codex CLI in repository directory...\n")
+ if os.Getenv("GITSYNCER_DEBUG") != "" {
+ if p, e := exec.LookPath("codex"); e == nil {
+ fmt.Printf(" codex path: %s\n", p)
+ }
+ }
+
+ attempts := [][]string{
+ {},
+ {"describe"},
+ {"describe", "."},
+ {"project", "describe"},
+ {"summary"},
+ }
+
+ for _, a := range attempts {
+ out, err := runCommandWithTimeout("codex", a...)
+ if err == nil {
+ trimmed := strings.TrimSpace(out)
+ if trimmed != "" {
+ summary = trimmed
+ break
+ }
+ } else if os.Getenv("GITSYNCER_DEBUG") != "" {
+ fmt.Printf(" codex %s failed: %v\n", strings.Join(a, " "), err)
+ }
+ }
+
+ if summary == "" {
+ // Fall back to providing a prompt and synthesized context via stdin
+ fmt.Printf(" Falling back to prompt + stdin payload\n")
+ contextPayload, fromReadme := buildAIInputContext(repoPath)
+ if fromReadme {
+ fmt.Printf(" Using README content as input\n")
+ } else {
+ fmt.Printf(" No README found; using synthesized repo context\n")
+ }
+ cmd = exec.Command("codex", prompt)
+ cmd.Stdin = strings.NewReader(contextPayload)
+ }
+ 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 := ""