diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-31 16:18:07 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-31 16:18:07 +0300 |
| commit | 45e9f23077ee8270542370e0c2307aa9dbecf63a (patch) | |
| tree | 45e45d5dea24445097b352dc418f0444cabb13d7 | |
| parent | e5cf30e8df255fe4d4d34db7fc076f26a2b84fee (diff) | |
some fixes
| -rw-r--r-- | internal/cli/showcase_only_handler.go | 114 | ||||
| -rw-r--r-- | internal/cmd/showcase.go | 62 | ||||
| -rw-r--r-- | internal/showcase/showcase.go | 228 |
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 := "" |
