Commit a2aed2947d90

Vincent Demeester <vincent@sbr.pm>
2026-01-14 04:32:45
refactor: migrate gh-pr and cliphist-cleanup to x repository
Move experimental Go tools to the separate x repository while keeping Nix package definitions in home/pkgs/. Changes: - Remove tool source code from tools/{gh-pr,cliphist-cleanup} - Add new Nix packages in pkgs/{gh-pr,cliphist-cleanup}/ - Update pkgs/default.nix to reference new package locations - Packages now fetch source from github.com/vdemeester/x - Use commit be677194b497 from x repository The x repository follows the "audience of one" philosophy for experimental code, while stable packaging remains in this repository. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 206e696
pkgs/cliphist-cleanup/default.nix
@@ -0,0 +1,30 @@
+{
+  lib,
+  buildGoModule,
+  fetchFromGitHub,
+}:
+
+buildGoModule rec {
+  pname = "cliphist-cleanup";
+  version = "0.1.0";
+
+  src = fetchFromGitHub {
+    owner = "vdemeester";
+    repo = "x";
+    rev = "be677194b497b7610395f9f269418224a9ad999d";
+    hash = "sha256-AfmqSrI7XicmM+tiAvB6bcAR4HHbQmxqeTTEyEXgdng=";
+  };
+
+  vendorHash = "sha256-hocnLCzWN8srQcO3BMNkd2lt0m54Qe7sqAhUxVZlz1k=";
+
+  # Build only the cliphist-cleanup command
+  subPackages = [ "cmd/cliphist-cleanup" ];
+
+  meta = {
+    description = "Clean up cliphist clipboard history by pattern matching";
+    homepage = "https://github.com/vdemeester/x";
+    license = lib.licenses.mit;
+    platforms = lib.platforms.linux;
+    mainProgram = "cliphist-cleanup";
+  };
+}
tools/gh-pr/default.nix → pkgs/gh-pr/default.nix
@@ -6,21 +6,28 @@
   gh,
   fzf,
   jq,
+  fetchFromGitHub,
 }:
 
-buildGoModule {
+buildGoModule rec {
   pname = "gh-pr";
   version = "0.1.0";
-  src = ./.;
 
-  vendorHash = "sha256-hocnLCzWN8srQcO3BMNkd2lt0m54Qe7sqAhUxVZlz1k="; # No external dependencies
+  src = fetchFromGitHub {
+    owner = "vdemeester";
+    repo = "x";
+    rev = "be677194b497b7610395f9f269418224a9ad999d";
+    hash = "sha256-AfmqSrI7XicmM+tiAvB6bcAR4HHbQmxqeTTEyEXgdng=";
+  };
+
+  vendorHash = "sha256-hocnLCzWN8srQcO3BMNkd2lt0m54Qe7sqAhUxVZlz1k=";
 
   nativeBuildInputs = [
     makeWrapper
     installShellFiles
   ];
 
-  # Build all binaries
+  # Build the gh-pr command
   subPackages = [ "cmd/gh-pr" ];
 
   # Wrap binary to include gh, fzf, and jq in PATH and install completions
@@ -42,7 +49,8 @@ buildGoModule {
   '';
 
   meta = {
-    description = "GitHub Pull Request management tool with template support, workflow restart, conflict resolution, and batch commenting";
+    description = "GitHub Pull Request management tool";
+    homepage = "https://github.com/vdemeester/x";
     license = lib.licenses.mit;
     platforms = lib.platforms.unix;
     mainProgram = "gh-pr";
pkgs/default.nix
@@ -22,10 +22,10 @@ in
   govanityurl = pkgs.callPackage ./govanityurl { };
   batzconverter = pkgs.callPackage ./batzconverter { };
   manifest-tool = pkgs.callPackage ./manifest-tool { };
-  gh-pr = pkgs.callPackage ../tools/gh-pr { };
+  gh-pr = pkgs.callPackage ./gh-pr { };
   arr = pkgs.callPackage ../tools/arr { };
   download-kiwix-zim = pkgs.callPackage ../tools/download-kiwix-zim { };
-  cliphist-cleanup = pkgs.callPackage ../tools/cliphist-cleanup { };
+  cliphist-cleanup = pkgs.callPackage ./cliphist-cleanup { };
   toggle-color-scheme = pkgs.callPackage ./toggle-color-scheme { };
   homepage = pkgs.callPackage ./homepage { inherit globals; };
   audible-converter = pkgs.callPackage ./audible-converter { };
tools/cliphist-cleanup/default.nix
@@ -1,19 +0,0 @@
-{
-  lib,
-  buildGoModule,
-}:
-
-buildGoModule {
-  pname = "cliphist-cleanup";
-  version = "0.1.0";
-  src = ./.;
-
-  vendorHash = null;
-
-  meta = {
-    description = "Clean up cliphist clipboard history by pattern matching";
-    license = lib.licenses.mit;
-    platforms = lib.platforms.linux;
-    mainProgram = "cliphist-cleanup";
-  };
-}
tools/cliphist-cleanup/go.mod
@@ -1,3 +0,0 @@
-module cliphist-cleanup
-
-go 1.25.4
tools/cliphist-cleanup/main.go
@@ -1,108 +0,0 @@
-package main
-
-import (
-	"bufio"
-	"fmt"
-	"os"
-	"os/exec"
-	"regexp"
-	"strings"
-)
-
-func main() {
-	if len(os.Args) < 2 {
-		fmt.Fprintln(os.Stderr, "Usage: cliphist-cleanup <pattern> [pattern2] [pattern3] ...")
-		fmt.Fprintln(os.Stderr, "")
-		fmt.Fprintln(os.Stderr, "Examples:")
-		fmt.Fprintln(os.Stderr, "  cliphist-cleanup 'Signed-off-by:'")
-		fmt.Fprintln(os.Stderr, "  cliphist-cleanup '^# This is' 'Co-Authored-By:'")
-		fmt.Fprintln(os.Stderr, "")
-		fmt.Fprintln(os.Stderr, "Patterns are treated as regular expressions (case-insensitive).")
-		os.Exit(1)
-	}
-
-	// Compile patterns
-	patterns := make([]*regexp.Regexp, 0, len(os.Args)-1)
-	for _, pattern := range os.Args[1:] {
-		re, err := regexp.Compile("(?i)" + pattern)
-		if err != nil {
-			fmt.Fprintf(os.Stderr, "Error compiling pattern '%s': %v\n", pattern, err)
-			os.Exit(1)
-		}
-		patterns = append(patterns, re)
-	}
-
-	// Get clipboard history
-	cmd := exec.Command("cliphist", "list")
-	stdout, err := cmd.StdoutPipe()
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "Error creating pipe: %v\n", err)
-		os.Exit(1)
-	}
-
-	if err := cmd.Start(); err != nil {
-		fmt.Fprintf(os.Stderr, "Error starting cliphist list: %v\n", err)
-		os.Exit(1)
-	}
-
-	// Read entries and delete matching ones
-	scanner := bufio.NewScanner(stdout)
-	deleted := 0
-	checked := 0
-
-	for scanner.Scan() {
-		line := scanner.Text()
-		checked++
-
-		// Check if line matches any pattern
-		matched := false
-		for _, re := range patterns {
-			if re.MatchString(line) {
-				matched = true
-				break
-			}
-		}
-
-		if matched {
-			// Delete this entry using delete-query
-			// The line format from cliphist list is: "ID\tCONTENT"
-			// We need to extract the content part after the tab
-			parts := strings.SplitN(line, "\t", 2)
-			if len(parts) < 2 {
-				continue
-			}
-
-			content := parts[1]
-			deleteCmd := exec.Command("cliphist", "delete-query", content)
-			if err := deleteCmd.Run(); err != nil {
-				fmt.Fprintf(os.Stderr, "Warning: Failed to delete entry: %v\n", err)
-			} else {
-				deleted++
-				fmt.Printf("Deleted: %s\n", truncate(content, 80))
-			}
-		}
-	}
-
-	if err := scanner.Err(); err != nil {
-		fmt.Fprintf(os.Stderr, "Error reading cliphist output: %v\n", err)
-		os.Exit(1)
-	}
-
-	if err := cmd.Wait(); err != nil {
-		fmt.Fprintf(os.Stderr, "Error waiting for cliphist: %v\n", err)
-		os.Exit(1)
-	}
-
-	fmt.Printf("\nChecked %d entries, deleted %d entries\n", checked, deleted)
-}
-
-func truncate(s string, maxLen int) string {
-	// Replace newlines with spaces for display
-	s = strings.ReplaceAll(s, "\n", " ")
-	s = strings.ReplaceAll(s, "\r", " ")
-
-	if len(s) <= maxLen {
-		return s
-	}
-	return s[:maxLen-3] + "..."
-}
tools/cliphist-cleanup/README.md
@@ -1,48 +0,0 @@
-# cliphist-cleanup
-
-Clean up your clipboard history by deleting entries matching specific patterns.
-
-## Usage
-
-```bash
-cliphist-cleanup <pattern> [pattern2] [pattern3] ...
-```
-
-Patterns are treated as case-insensitive regular expressions.
-
-## Examples
-
-Delete all git sign-off entries:
-```bash
-cliphist-cleanup 'Signed-off-by:'
-```
-
-Delete multiple types of git commit messages:
-```bash
-cliphist-cleanup '# This is a combination' 'Co-Authored-By:'
-```
-
-Delete entries starting with specific text:
-```bash
-cliphist-cleanup '^password:' '^token:'
-```
-
-## How It Works
-
-1. Lists all clipboard history entries using `cliphist list`
-2. Matches each entry against the provided patterns
-3. Deletes matching entries using `cliphist delete-query`
-4. Reports the number of entries checked and deleted
-
-## Building
-
-```bash
-nix build .#cliphist-cleanup
-```
-
-## Installing
-
-Add to your home-manager packages or install directly:
-```bash
-nix profile install .#cliphist-cleanup
-```
tools/gh-pr/cmd/gh-pr/approve.go
@@ -1,374 +0,0 @@
-package main
-
-import (
-	"bufio"
-	"bytes"
-	"fmt"
-	"os"
-	"os/exec"
-	"strings"
-
-	"github.com/spf13/cobra"
-	"github.com/vdemeester/home/tools/gh-pr/internal/output"
-)
-
-func approveCmd(out *output.Writer) *cobra.Command {
-	var (
-		prow          bool
-		merge         bool
-		force         bool
-		comment       string
-		editorComment bool
-		interactive   bool
-	)
-
-	cmd := &cobra.Command{
-		Use:   "approve [projects...]",
-		Short: "Approve pull requests",
-		Long: `Select and approve pull requests using fzf.
-
-This command lists pull requests from specified GitHub repositories and allows
-you to select and approve them interactively. You can optionally merge approved
-PRs and add custom comments.
-
-If no projects are specified, uses default Tekton projects:
-- tektoncd/pipeline
-- tektoncd/plumbing
-- tektoncd/cli
-- tektoncd/mcp-server
-
-Examples:
-  # Approve PRs from default projects
-  gh-pr approve
-
-  # Approve PRs from specific projects
-  gh-pr approve tektoncd/pipeline tektoncd/cli
-
-  # Approve with Prow comment
-  gh-pr approve -p tektoncd/pipeline
-
-  # Approve and merge
-  gh-pr approve -m tektoncd/pipeline
-
-  # Approve with custom comment
-  gh-pr approve -c "LGTM! Great work" tektoncd/pipeline
-
-  # Approve with editor for multi-line comment
-  gh-pr approve -C tektoncd/pipeline
-
-  # Force merge (requires admin)
-  gh-pr approve -m -f tektoncd/pipeline`,
-		RunE: func(cmd *cobra.Command, args []string) error {
-			projects := args
-			if len(projects) == 0 {
-				// Use default Tekton projects
-				projects = []string{
-					"tektoncd/pipeline",
-					"tektoncd/plumbing",
-					"tektoncd/cli",
-					"tektoncd/mcp-server",
-				}
-			}
-
-			return runApprove(out, approveOpts{
-				projects:      projects,
-				prow:          prow,
-				merge:         merge,
-				force:         force,
-				comment:       comment,
-				editorComment: editorComment,
-				interactive:   interactive,
-			})
-		},
-	}
-
-	cmd.Flags().BoolVarP(&prow, "prow", "p", false, "Add Prow /lgtm comment")
-	cmd.Flags().BoolVarP(&merge, "merge", "m", false, "Merge PR after approval")
-	cmd.Flags().BoolVarP(&force, "force", "f", false, "Force merge with --admin (requires admin rights)")
-	cmd.Flags().StringVarP(&comment, "comment", "c", "", "Custom approval comment")
-	cmd.Flags().BoolVarP(&editorComment, "editor", "C", false, "Open editor for multi-line comment")
-	cmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Prompt for comment interactively")
-
-	return cmd
-}
-
-type approveOpts struct {
-	projects      []string
-	prow          bool
-	merge         bool
-	force         bool
-	comment       string
-	editorComment bool
-	interactive   bool
-}
-
-func runApprove(out *output.Writer, opts approveOpts) error {
-	// Check if fzf is available
-	if _, err := exec.LookPath("fzf"); err != nil {
-		return fmt.Errorf("fzf is required but not found in PATH: %w", err)
-	}
-
-	// Handle comment options
-	commentBody := opts.comment
-	if opts.editorComment {
-		body, err := getEditorComment()
-		if err != nil {
-			return fmt.Errorf("failed to get editor comment: %w", err)
-		}
-		commentBody = body
-	} else if commentBody == "" && opts.interactive {
-		out.Info("Enter approval comment (press Enter to skip):")
-		reader := bufio.NewReader(os.Stdin)
-		line, err := reader.ReadString('\n')
-		if err != nil {
-			return fmt.Errorf("failed to read comment: %w", err)
-		}
-		commentBody = strings.TrimSpace(line)
-	}
-
-	// Process each project
-	for _, project := range opts.projects {
-		out.Info("Processing project: %s", project)
-
-		if err := processProject(out, project, commentBody, opts); err != nil {
-			out.Error("Failed to process project %s: %v", project, err)
-			continue
-		}
-	}
-
-	return nil
-}
-
-func processProject(out *output.Writer, project, commentBody string, opts approveOpts) error {
-	// Build gh pr list command
-	ghArgs := []string{"pr", "list", "--repo", project, "--json", "number,title,author"}
-
-	ghCmd := exec.Command("gh", ghArgs...)
-	ghOutput, err := ghCmd.Output()
-	if err != nil {
-		if exitErr, ok := err.(*exec.ExitError); ok {
-			return fmt.Errorf("gh pr list failed: %s", exitErr.Stderr)
-		}
-		return fmt.Errorf("gh pr list failed: %w", err)
-	}
-
-	prListJSON := strings.TrimSpace(string(ghOutput))
-	if prListJSON == "" || prListJSON == "[]" {
-		out.Warning("No pull requests found in %s", project)
-		return nil
-	}
-
-	// Format PR list for fzf using jq
-	jqCmd := exec.Command("jq", "-r",
-		`.[] | (.number | tostring) + "\t" + .author.login + "\t" + .title`)
-	jqCmd.Stdin = strings.NewReader(prListJSON)
-	jqOutput, err := jqCmd.Output()
-	if err != nil {
-		return fmt.Errorf("failed to format PR list: %w", err)
-	}
-
-	formattedList := strings.TrimSpace(string(jqOutput))
-	if formattedList == "" {
-		out.Warning("No pull requests to display in %s", project)
-		return nil
-	}
-
-	// Use column to align the output
-	columnCmd := exec.Command("column", "-t", "-s", "\t")
-	columnCmd.Stdin = strings.NewReader(formattedList)
-	columnOutput, err := columnCmd.Output()
-	if err != nil {
-		// If column fails, use the original formatted list
-		columnOutput = []byte(formattedList)
-	}
-
-	// Build preview command for fzf
-	previewCmd := fmt.Sprintf(`gh pr checks --repo=%s {1} --json 'name,state' 2>/dev/null | \
-		jq -r 'map(.state + ": " + .name) | .[]' || echo "No checks found"`, project)
-
-	// Use fzf for multi-select with preview
-	out.Info("Select PRs to approve from %s (Tab: multi-select, Enter: confirm)...", project)
-	fzfCmd := exec.Command("fzf",
-		"--multi",
-		"--ansi",
-		"--header", fmt.Sprintf("Select PRs from %s (Tab: select, Enter: confirm)", project),
-		"--preview", previewCmd,
-		"--preview-window", "right:50%:wrap",
-	)
-	fzfCmd.Stdin = bytes.NewReader(columnOutput)
-	fzfCmd.Stderr = os.Stderr
-
-	selectedOutput, err := fzfCmd.Output()
-	if err != nil {
-		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 130 {
-			// User cancelled with Ctrl+C
-			out.Info("Selection cancelled for %s", project)
-			return nil
-		}
-		return fmt.Errorf("fzf selection failed: %w", err)
-	}
-
-	selectedPRs := strings.TrimSpace(string(selectedOutput))
-	if selectedPRs == "" {
-		out.Info("No pull requests selected from %s", project)
-		return nil
-	}
-
-	// Extract PR numbers from selected lines
-	prNumbers := []string{}
-	scanner := bufio.NewScanner(strings.NewReader(selectedPRs))
-	for scanner.Scan() {
-		line := scanner.Text()
-		// Extract PR number from the first field
-		fields := strings.Fields(line)
-		if len(fields) > 0 {
-			prNumbers = append(prNumbers, fields[0])
-		}
-	}
-
-	if len(prNumbers) == 0 {
-		out.Warning("No valid PR numbers found in selection")
-		return nil
-	}
-
-	out.Success("Selected %d pull request(s): %s", len(prNumbers), strings.Join(prNumbers, ", "))
-
-	// Approve each PR
-	failed := []string{}
-	for _, prNum := range prNumbers {
-		if err := approvePR(out, project, prNum, commentBody, opts); err != nil {
-			out.Error("Failed to approve PR #%s: %v", prNum, err)
-			failed = append(failed, prNum)
-			continue
-		}
-
-		// Merge if requested
-		if opts.merge {
-			if err := mergePR(out, project, prNum, commentBody, opts.force); err != nil {
-				out.Error("Failed to merge PR #%s: %v", prNum, err)
-				failed = append(failed, prNum)
-			}
-		}
-	}
-
-	// Summary
-	successCount := len(prNumbers) - len(failed)
-	if successCount > 0 {
-		out.Success("\nSuccessfully processed %d pull request(s) from %s", successCount, project)
-	}
-	if len(failed) > 0 {
-		out.Error("Failed to process %d pull request(s) from %s: %s",
-			len(failed), project, strings.Join(failed, ", "))
-	}
-
-	return nil
-}
-
-func approvePR(out *output.Writer, repo, prNum, commentBody string, opts approveOpts) error {
-	// Build review body
-	reviewBody := commentBody
-	if opts.prow {
-		if reviewBody != "" {
-			reviewBody = "/lgtm\n\n" + reviewBody
-		} else {
-			reviewBody = "/lgtm"
-		}
-	}
-
-	// Build gh pr review command
-	reviewArgs := []string{"pr", "review", "--repo", repo, prNum, "--approve"}
-	if reviewBody != "" {
-		reviewArgs = append(reviewArgs, "--body", reviewBody)
-	}
-
-	reviewCmd := exec.Command("gh", reviewArgs...)
-	if output, err := reviewCmd.CombinedOutput(); err != nil {
-		return fmt.Errorf("gh pr review failed: %s", output)
-	}
-
-	out.Success("✓ Approved PR #%s", prNum)
-	return nil
-}
-
-func mergePR(out *output.Writer, repo, prNum, commentBody string, force bool) error {
-	// Build merge args
-	mergeArgs := []string{"pr", "merge", "--repo", repo}
-
-	if force {
-		mergeArgs = append(mergeArgs, "--admin")
-		out.Info("Merging PR #%s with admin privileges...", prNum)
-	} else {
-		mergeArgs = append(mergeArgs, "--auto")
-	}
-
-	mergeArgs = append(mergeArgs, "--rebase", "--delete-branch")
-
-	if commentBody != "" {
-		mergeArgs = append(mergeArgs, "--body", commentBody)
-	}
-
-	mergeArgs = append(mergeArgs, prNum)
-
-	// Execute merge
-	mergeCmd := exec.Command("gh", mergeArgs...)
-	if output, err := mergeCmd.CombinedOutput(); err != nil {
-		return fmt.Errorf("gh pr merge failed: %s", output)
-	}
-
-	out.Success("✓ Merged PR #%s", prNum)
-	return nil
-}
-
-func getEditorComment() (string, error) {
-	// Create temporary file
-	tmpfile, err := os.CreateTemp("", "gh-pr-comment-*.md")
-	if err != nil {
-		return "", fmt.Errorf("failed to create temp file: %w", err)
-	}
-	defer os.Remove(tmpfile.Name())
-
-	// Write template
-	template := `# Enter your approval comment below
-# Lines starting with # will be ignored
-# Save and close the editor to continue
-
-`
-	if _, err := tmpfile.WriteString(template); err != nil {
-		return "", fmt.Errorf("failed to write template: %w", err)
-	}
-	tmpfile.Close()
-
-	// Get editor
-	editor := os.Getenv("EDITOR")
-	if editor == "" {
-		editor = "vim"
-	}
-
-	// Open editor
-	editorCmd := exec.Command(editor, tmpfile.Name())
-	editorCmd.Stdin = os.Stdin
-	editorCmd.Stdout = os.Stdout
-	editorCmd.Stderr = os.Stderr
-
-	if err := editorCmd.Run(); err != nil {
-		return "", fmt.Errorf("editor failed: %w", err)
-	}
-
-	// Read back the file
-	content, err := os.ReadFile(tmpfile.Name())
-	if err != nil {
-		return "", fmt.Errorf("failed to read comment file: %w", err)
-	}
-
-	// Filter out comment lines and empty lines
-	var lines []string
-	scanner := bufio.NewScanner(bytes.NewReader(content))
-	for scanner.Scan() {
-		line := scanner.Text()
-		if !strings.HasPrefix(line, "#") && strings.TrimSpace(line) != "" {
-			lines = append(lines, line)
-		}
-	}
-
-	return strings.Join(lines, "\n"), nil
-}
tools/gh-pr/cmd/gh-pr/cleanup.go
@@ -1,422 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"regexp"
-	"strings"
-
-	"github.com/spf13/cobra"
-	"github.com/vdemeester/home/tools/gh-pr/internal/output"
-)
-
-func cleanupCmd(out *output.Writer) *cobra.Command {
-	var (
-		worktreeDir    string
-		dryRun         bool
-		checkMerged    bool
-		checkUpstream  bool
-		force          bool
-		upstreamBranch string
-	)
-
-	cmd := &cobra.Command{
-		Use:   "cleanup [DIRECTORY]",
-		Short: "Clean up git worktrees without uncommitted changes",
-		Long: `Remove git worktrees that have no uncommitted changes.
-
-This command scans for git repositories with worktrees and removes those that:
-  - Have no uncommitted changes (clean working directory)
-  - Optionally: are merged upstream (with --check-upstream)
-  - Optionally: have merged/closed PRs (with --check-merged)
-
-By default, it scans the directory used by 'resolve-conflicts' command.
-
-Examples:
-  # Clean up default worktree directory (dry run)
-  gh-pr cleanup --dry-run
-
-  # Clean up and remove worktrees
-  gh-pr cleanup
-
-  # Clean up custom directory
-  gh-pr cleanup ~/my-worktrees
-
-  # Check if commits are merged upstream before removing
-  gh-pr cleanup --check-upstream
-
-  # Check if PR is merged before removing
-  gh-pr cleanup --check-merged
-
-  # Check both upstream and PR status
-  gh-pr cleanup --check-upstream --check-merged
-
-  # Force remove even with uncommitted changes (dangerous!)
-  gh-pr cleanup --force`,
-		RunE: func(cmd *cobra.Command, args []string) error {
-			// Override worktreeDir if provided as argument
-			if len(args) > 0 {
-				worktreeDir = args[0]
-			}
-
-			return runCleanup(out, cleanupOpts{
-				worktreeDir:    worktreeDir,
-				dryRun:         dryRun,
-				checkMerged:    checkMerged,
-				checkUpstream:  checkUpstream,
-				force:          force,
-				upstreamBranch: upstreamBranch,
-			})
-		},
-	}
-
-	cmd.Flags().StringVarP(&worktreeDir, "worktree", "w", "/tmp/gh-resolve-conflicts-worktrees", "Directory containing worktrees")
-	cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Show what would be removed without removing")
-	cmd.Flags().BoolVarP(&checkMerged, "check-merged", "m", false, "Only remove worktrees for merged/closed PRs")
-	cmd.Flags().BoolVarP(&checkUpstream, "check-upstream", "u", false, "Check if commits are merged upstream")
-	cmd.Flags().StringVarP(&upstreamBranch, "upstream-branch", "b", "main", "Upstream branch to check against")
-	cmd.Flags().BoolVarP(&force, "force", "f", false, "Force remove even with uncommitted changes")
-
-	return cmd
-}
-
-type cleanupOpts struct {
-	worktreeDir    string
-	dryRun         bool
-	checkMerged    bool
-	checkUpstream  bool
-	force          bool
-	upstreamBranch string
-}
-
-type mergeStatus struct {
-	prMerged        bool
-	upstreamMerged  bool
-	prChecked       bool
-	upstreamChecked bool
-}
-
-type worktreeInfo struct {
-	path   string
-	branch string
-	prNum  string
-	repo   string
-}
-
-func runCleanup(out *output.Writer, opts cleanupOpts) error {
-	if _, err := os.Stat(opts.worktreeDir); os.IsNotExist(err) {
-		out.Warning("Directory does not exist: %s", opts.worktreeDir)
-		return nil
-	}
-
-	out.Info("Scanning for git worktrees in: %s", opts.worktreeDir)
-	fmt.Println()
-
-	// Find all directories that might contain git repos
-	entries, err := os.ReadDir(opts.worktreeDir)
-	if err != nil {
-		return fmt.Errorf("failed to read directory: %w", err)
-	}
-
-	totalRemoved := 0
-	totalKept := 0
-
-	for _, entry := range entries {
-		if !entry.IsDir() {
-			continue
-		}
-
-		repoName := entry.Name()
-		repoPath := filepath.Join(opts.worktreeDir, repoName)
-
-		// Look for main directory (the base repo)
-		mainPath := filepath.Join(repoPath, "main")
-		if _, err := os.Stat(filepath.Join(mainPath, ".git")); os.IsNotExist(err) {
-			continue
-		}
-
-		out.Info("📁 Repository: %s", repoName)
-
-		// Get list of worktrees
-		worktrees, err := getWorktrees(mainPath)
-		if err != nil {
-			out.Error("Failed to list worktrees: %v", err)
-			continue
-		}
-
-		if len(worktrees) == 0 {
-			out.Info("  No worktrees found")
-			fmt.Println()
-			continue
-		}
-
-		// Process each worktree
-		for _, wt := range worktrees {
-			wt.repo = repoName
-			removed, kept, err := processWorktree(out, mainPath, wt, opts)
-			if err != nil {
-				out.Error("  Failed to process worktree %s: %v", wt.path, err)
-				continue
-			}
-			totalRemoved += removed
-			totalKept += kept
-		}
-
-		fmt.Println()
-	}
-
-	// Summary
-	if opts.dryRun {
-		out.Success("Dry run complete. Would remove %d worktrees, keeping %d", totalRemoved, totalKept)
-	} else {
-		out.Success("Cleanup complete! Removed %d worktrees, kept %d", totalRemoved, totalKept)
-	}
-
-	return nil
-}
-
-func getWorktrees(mainPath string) ([]worktreeInfo, error) {
-	cmd := exec.Command("git", "-C", mainPath, "worktree", "list", "--porcelain")
-	output, err := cmd.Output()
-	if err != nil {
-		return nil, err
-	}
-
-	var worktrees []worktreeInfo
-	var current worktreeInfo
-	prPattern := regexp.MustCompile(`^pr-(\d+)$`)
-
-	for _, line := range strings.Split(string(output), "\n") {
-		line = strings.TrimSpace(line)
-		if line == "" {
-			if current.path != "" && !strings.HasSuffix(current.path, "/main") {
-				// Extract PR number from branch name if it matches pr-XXX
-				if matches := prPattern.FindStringSubmatch(current.branch); len(matches) > 1 {
-					current.prNum = matches[1]
-				}
-				worktrees = append(worktrees, current)
-			}
-			current = worktreeInfo{}
-			continue
-		}
-
-		if strings.HasPrefix(line, "worktree ") {
-			current.path = strings.TrimPrefix(line, "worktree ")
-		} else if strings.HasPrefix(line, "branch ") {
-			branch := strings.TrimPrefix(line, "branch ")
-			// Remove refs/heads/ prefix
-			current.branch = strings.TrimPrefix(branch, "refs/heads/")
-		}
-	}
-
-	// Don't forget the last one
-	if current.path != "" && !strings.HasSuffix(current.path, "/main") {
-		if matches := prPattern.FindStringSubmatch(current.branch); len(matches) > 1 {
-			current.prNum = matches[1]
-		}
-		worktrees = append(worktrees, current)
-	}
-
-	return worktrees, nil
-}
-
-func processWorktree(out *output.Writer, mainPath string, wt worktreeInfo, opts cleanupOpts) (removed int, kept int, err error) {
-	name := filepath.Base(wt.path)
-
-	// Check if worktree still exists
-	if _, err := os.Stat(wt.path); os.IsNotExist(err) {
-		out.Warning("  ⚠️  %s [%s] - path does not exist, pruning", name, wt.branch)
-		if !opts.dryRun {
-			cmd := exec.Command("git", "-C", mainPath, "worktree", "prune")
-			if err := cmd.Run(); err != nil {
-				return 0, 0, fmt.Errorf("failed to prune: %w", err)
-			}
-		}
-		return 1, 0, nil
-	}
-
-	// Check for uncommitted changes
-	hasChanges, err := hasUncommittedChanges(wt.path)
-	if err != nil {
-		return 0, 0, err
-	}
-
-	if hasChanges && !opts.force {
-		out.Warning("  ⚠️  %s [%s] - has uncommitted changes (keeping)", name, wt.branch)
-		return 0, 1, nil
-	}
-
-	// Check merge status
-	status := mergeStatus{}
-
-	// Check if PR is merged/closed (if requested)
-	if opts.checkMerged && wt.prNum != "" {
-		merged, err := isPRMergedOrClosed(wt.repo, wt.prNum)
-		if err != nil {
-			out.Warning("  ⚠️  %s [%s] - failed to check PR status: %v (keeping)", name, wt.branch, err)
-			return 0, 1, nil
-		}
-		status.prMerged = merged
-		status.prChecked = true
-	}
-
-	// Check if commits are merged upstream (if requested)
-	if opts.checkUpstream {
-		merged, err := isBranchMergedUpstream(mainPath, wt.branch, opts.upstreamBranch)
-		if err != nil {
-			// Don't fail, just note we couldn't check
-			status.upstreamChecked = false
-		} else {
-			status.upstreamMerged = merged
-			status.upstreamChecked = true
-		}
-	}
-
-	// Decide whether to keep or remove based on status
-	shouldRemove, reason := shouldRemoveWorktree(status, opts, wt)
-
-	if !shouldRemove {
-		printKeepStatus(out, name, wt.branch, wt.prNum, status, reason)
-		return 0, 1, nil
-	}
-
-	// Remove the worktree
-	printRemoveStatus(out, name, wt.branch, wt.prNum, status, opts.dryRun)
-
-	if !opts.dryRun {
-		cmd := exec.Command("git", "-C", mainPath, "worktree", "remove", wt.path)
-		if err := cmd.Run(); err != nil {
-			// Try with --force
-			out.Warning("     Retrying with --force...")
-			cmd = exec.Command("git", "-C", mainPath, "worktree", "remove", "--force", wt.path)
-			if err := cmd.Run(); err != nil {
-				return 0, 0, fmt.Errorf("failed to remove: %w", err)
-			}
-		}
-	}
-
-	return 1, 0, nil
-}
-
-func shouldRemoveWorktree(status mergeStatus, opts cleanupOpts, wt worktreeInfo) (bool, string) {
-	// If we're checking merge status, only remove if something is merged
-	if opts.checkMerged || opts.checkUpstream {
-		if status.prChecked && status.prMerged {
-			return true, "PR merged"
-		}
-		if status.upstreamChecked && status.upstreamMerged {
-			return true, "commits merged upstream"
-		}
-		// Neither merged, keep it
-		if status.prChecked && !status.prMerged {
-			return false, "PR still open"
-		}
-		if status.upstreamChecked && !status.upstreamMerged {
-			return false, "not merged upstream"
-		}
-		return false, "merge status unknown"
-	}
-
-	// If not checking merge status, remove all clean worktrees
-	return true, "clean"
-}
-
-func printKeepStatus(out *output.Writer, name, branch, prNum string, status mergeStatus, reason string) {
-	prLabel := ""
-	if prNum != "" {
-		prLabel = fmt.Sprintf(" PR #%s", prNum)
-	}
-
-	statusInfo := ""
-	if status.upstreamChecked {
-		if status.upstreamMerged {
-			statusInfo = " [merged upstream]"
-		} else {
-			statusInfo = " [not merged upstream]"
-		}
-	}
-	if status.prChecked {
-		if status.prMerged {
-			statusInfo += " [PR merged]"
-		} else {
-			statusInfo += " [PR open]"
-		}
-	}
-
-	out.Info("  ℹ️  %s [%s]%s%s - %s (keeping)", name, branch, prLabel, statusInfo, reason)
-}
-
-func printRemoveStatus(out *output.Writer, name, branch, prNum string, status mergeStatus, dryRun bool) {
-	prLabel := ""
-	if prNum != "" {
-		prLabel = fmt.Sprintf(" PR #%s", prNum)
-	}
-
-	action := "removing"
-	if dryRun {
-		action = "would remove"
-	}
-
-	// Use different emojis/colors based on merge status
-	if status.upstreamMerged && status.upstreamChecked {
-		// Merged upstream - use checkmark (green by default via Success)
-		fmt.Printf("  ✅ %s [%s]%s - merged upstream (%s)\n", name, branch, prLabel, action)
-	} else if status.prMerged && status.prChecked {
-		// PR merged but maybe not upstream yet - use green circle
-		fmt.Printf("  ✅ %s [%s]%s - PR merged (%s)\n", name, branch, prLabel, action)
-	} else {
-		// Clean but not verified as merged - use trash can (standard removal)
-		fmt.Printf("  🗑️  %s [%s]%s - clean (%s)\n", name, branch, prLabel, action)
-	}
-}
-
-func hasUncommittedChanges(path string) (bool, error) {
-	cmd := exec.Command("git", "-C", path, "status", "--porcelain")
-	output, err := cmd.Output()
-	if err != nil {
-		return false, err
-	}
-	return len(strings.TrimSpace(string(output))) > 0, nil
-}
-
-func isPRMergedOrClosed(repo, prNum string) (bool, error) {
-	// Use gh to check PR state
-	cmd := exec.Command("gh", "pr", "view", prNum, "--repo", repo, "--json", "state", "--jq", ".state")
-	output, err := cmd.Output()
-	if err != nil {
-		return false, err
-	}
-
-	state := strings.TrimSpace(string(output))
-	return state == "MERGED" || state == "CLOSED", nil
-}
-
-func isBranchMergedUpstream(repoPath, branch, upstreamBranch string) (bool, error) {
-	// First, check if the branch exists
-	checkCmd := exec.Command("git", "-C", repoPath, "rev-parse", "--verify", branch)
-	if err := checkCmd.Run(); err != nil {
-		// Branch doesn't exist, consider it merged/deleted
-		return true, nil
-	}
-
-	// Check if there are commits in the branch that are not in upstream
-	// git cherry returns commits that exist in branch but not in upstream
-	// Empty output means all commits are merged
-	cmd := exec.Command("git", "-C", repoPath, "cherry", upstreamBranch, branch)
-	output, err := cmd.Output()
-	if err != nil {
-		// If cherry fails, try a different approach: check if branch is ancestor of upstream
-		mergeBaseCmd := exec.Command("git", "-C", repoPath, "merge-base", "--is-ancestor", branch, upstreamBranch)
-		if mergeBaseErr := mergeBaseCmd.Run(); mergeBaseErr == nil {
-			// Branch is ancestor of upstream, so it's merged
-			return true, nil
-		}
-		return false, err
-	}
-
-	// If output is empty, all commits from branch are in upstream
-	result := strings.TrimSpace(string(output))
-	return result == "", nil
-}
tools/gh-pr/cmd/gh-pr/comment.go
@@ -1,233 +0,0 @@
-package main
-
-import (
-	"bufio"
-	"bytes"
-	"fmt"
-	"os"
-	"os/exec"
-	"strings"
-
-	"github.com/spf13/cobra"
-	"github.com/vdemeester/home/tools/gh-pr/internal/output"
-)
-
-func commentCmd(out *output.Writer) *cobra.Command {
-	var (
-		body   string
-		repo   string
-		labels []string
-		author string
-		state  string
-	)
-
-	cmd := &cobra.Command{
-		Use:   "comment",
-		Short: "Comment on multiple pull requests",
-		Long: `Select and comment on multiple pull requests using fzf.
-
-This command lists pull requests and allows you to select multiple PRs
-using fzf's multi-select feature. You can then add the same comment to
-all selected PRs.
-
-Examples:
-  gh-pr comment                           # Select PRs interactively
-  gh-pr comment --body "LGTM"            # Pre-specify the comment
-  gh-pr comment --label bug              # Filter by label
-  gh-pr comment --repo owner/repo        # Work with a specific repo
-  gh-pr comment --state all              # Include closed PRs`,
-		RunE: func(cmd *cobra.Command, args []string) error {
-			return runComment(out, commentOpts{
-				body:   body,
-				repo:   repo,
-				labels: labels,
-				author: author,
-				state:  state,
-			})
-		},
-	}
-
-	cmd.Flags().StringVarP(&body, "body", "b", "", "Comment body (will prompt if not provided)")
-	cmd.Flags().StringVarP(&repo, "repo", "R", "", "Repository (owner/repo format)")
-	cmd.Flags().StringSliceVarP(&labels, "label", "l", nil, "Filter PRs by label (comma-separated)")
-	cmd.Flags().StringVarP(&author, "author", "a", "", "Filter PRs by author")
-	cmd.Flags().StringVarP(&state, "state", "s", "open", "Filter by state: open, closed, merged, all")
-
-	return cmd
-}
-
-type commentOpts struct {
-	body   string
-	repo   string
-	labels []string
-	author string
-	state  string
-}
-
-func runComment(out *output.Writer, opts commentOpts) error {
-	// Check if fzf is available
-	if _, err := exec.LookPath("fzf"); err != nil {
-		return fmt.Errorf("fzf is required but not found in PATH: %w", err)
-	}
-
-	// Build gh pr list command
-	ghArgs := []string{"pr", "list"}
-
-	if opts.repo != "" {
-		ghArgs = append(ghArgs, "--repo", opts.repo)
-	}
-
-	for _, label := range opts.labels {
-		ghArgs = append(ghArgs, "--label", label)
-	}
-
-	if opts.author != "" {
-		ghArgs = append(ghArgs, "--author", opts.author)
-	}
-
-	if opts.state != "" {
-		ghArgs = append(ghArgs, "--state", opts.state)
-	}
-
-	// Get list of PRs
-	out.Info("Fetching pull requests...")
-	ghCmd := exec.Command("gh", ghArgs...)
-	ghOutput, err := ghCmd.Output()
-	if err != nil {
-		if exitErr, ok := err.(*exec.ExitError); ok {
-			return fmt.Errorf("gh pr list failed: %s", exitErr.Stderr)
-		}
-		return fmt.Errorf("gh pr list failed: %w", err)
-	}
-
-	prList := strings.TrimSpace(string(ghOutput))
-	if prList == "" {
-		out.Warning("No pull requests found matching the criteria.")
-		return nil
-	}
-
-	// Build preview command for fzf
-	repoFlag := ""
-	if opts.repo != "" {
-		repoFlag = fmt.Sprintf("-R %s", opts.repo)
-	}
-
-	previewCmd := fmt.Sprintf(`gh pr view {1} %s --json number,title,author,statusCheckRollup | \
-		jq -r '"# PR " + (.number | tostring) + ": " + .title,
-		       "",
-		       "Author: @" + .author.login,
-		       "",
-		       "## Status Checks:",
-		       "",
-		       (if (.statusCheckRollup // [] | length) == 0 then "  (No status checks)"
-		        else (.statusCheckRollup | map(
-		         "  " + (
-		           if .conclusion == "SUCCESS" then "✓"
-		           elif .conclusion == "FAILURE" then "✗"
-		           elif .conclusion == "PENDING" then "●"
-		           elif .conclusion == "SKIPPED" then "○"
-		           else "?"
-		           end
-		         ) + " " + .name + " (" + (.conclusion // "unknown") + ")"
-		       ) | join("\n"))
-		        end)'`, repoFlag)
-
-	// Use fzf for multi-select with preview
-	out.Info("Select pull requests (use Tab to select multiple, Enter to confirm)...")
-	fzfCmd := exec.Command("fzf",
-		"--multi",
-		"--ansi",
-		"--header", "Select PRs (Tab: select, Enter: confirm)",
-		"--preview", previewCmd,
-		"--preview-window", "right:60%:wrap",
-	)
-	fzfCmd.Stdin = strings.NewReader(prList)
-	fzfCmd.Stderr = os.Stderr
-
-	selectedOutput, err := fzfCmd.Output()
-	if err != nil {
-		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 130 {
-			// User cancelled with Ctrl+C
-			out.Info("Selection cancelled.")
-			return nil
-		}
-		return fmt.Errorf("fzf selection failed: %w", err)
-	}
-
-	selectedPRs := strings.TrimSpace(string(selectedOutput))
-	if selectedPRs == "" {
-		out.Info("No pull requests selected.")
-		return nil
-	}
-
-	// Extract PR numbers from selected lines
-	prNumbers := []string{}
-	scanner := bufio.NewScanner(strings.NewReader(selectedPRs))
-	for scanner.Scan() {
-		line := scanner.Text()
-		// Extract PR number from the first field (format: "123  title...")
-		fields := strings.Fields(line)
-		if len(fields) > 0 {
-			prNumbers = append(prNumbers, fields[0])
-		}
-	}
-
-	if len(prNumbers) == 0 {
-		out.Warning("No valid PR numbers found in selection.")
-		return nil
-	}
-
-	out.Success("Selected %d pull request(s): %s", len(prNumbers), strings.Join(prNumbers, ", "))
-
-	// Get comment body if not provided
-	commentBody := opts.body
-	if commentBody == "" {
-		out.Info("Enter your comment (Ctrl+D when done):")
-		var buf bytes.Buffer
-		scanner := bufio.NewScanner(os.Stdin)
-		for scanner.Scan() {
-			buf.WriteString(scanner.Text())
-			buf.WriteString("\n")
-		}
-		if err := scanner.Err(); err != nil {
-			return fmt.Errorf("failed to read comment: %w", err)
-		}
-		commentBody = strings.TrimSpace(buf.String())
-	}
-
-	if commentBody == "" {
-		out.Warning("Empty comment body. No comments will be posted.")
-		return nil
-	}
-
-	// Comment on each PR
-	out.Info("Posting comment to %d pull request(s)...", len(prNumbers))
-	failed := []string{}
-
-	for _, prNum := range prNumbers {
-		commentArgs := []string{"pr", "comment", prNum, "--body", commentBody}
-		if opts.repo != "" {
-			commentArgs = append(commentArgs, "--repo", opts.repo)
-		}
-
-		commentCmd := exec.Command("gh", commentArgs...)
-		if err := commentCmd.Run(); err != nil {
-			out.Error("Failed to comment on PR #%s: %v", prNum, err)
-			failed = append(failed, prNum)
-		} else {
-			out.Success("Commented on PR #%s", prNum)
-		}
-	}
-
-	// Summary
-	successCount := len(prNumbers) - len(failed)
-	if successCount > 0 {
-		out.Success("\nSuccessfully commented on %d pull request(s)", successCount)
-	}
-	if len(failed) > 0 {
-		out.Error("Failed to comment on %d pull request(s): %s", len(failed), strings.Join(failed, ", "))
-		return fmt.Errorf("some comments failed")
-	}
-
-	return nil
-}
tools/gh-pr/cmd/gh-pr/create.go
@@ -1,182 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"os"
-	"os/exec"
-	"strings"
-
-	"github.com/spf13/cobra"
-	"github.com/vdemeester/home/tools/gh-pr/internal/output"
-	"github.com/vdemeester/home/tools/gh-pr/internal/templates"
-)
-
-func createCmd(out *output.Writer) *cobra.Command {
-	var (
-		title     string
-		body      string
-		template  string
-		draft     bool
-		base      string
-		head      string
-		web       bool
-		reviewers []string
-		assignees []string
-		labels    []string
-		refresh   bool
-	)
-
-	cmd := &cobra.Command{
-		Use:   "create",
-		Short: "Create a pull request",
-		Long: `Create a pull request with optional template support.
-
-Templates are automatically discovered from:
-  - .github/PULL_REQUEST_TEMPLATE.md
-  - .github/PULL_REQUEST_TEMPLATE/
-  - docs/PULL_REQUEST_TEMPLATE.md
-
-Use --template to specify a template file, or list available templates
-with 'gh-pr list-templates'.`,
-		RunE: func(cmd *cobra.Command, args []string) error {
-			return runCreate(out, createOpts{
-				title:     title,
-				body:      body,
-				template:  template,
-				draft:     draft,
-				base:      base,
-				head:      head,
-				web:       web,
-				reviewers: reviewers,
-				assignees: assignees,
-				labels:    labels,
-				refresh:   refresh,
-			})
-		},
-	}
-
-	cmd.Flags().StringVarP(&title, "title", "t", "", "Pull request title")
-	cmd.Flags().StringVarP(&body, "body", "b", "", "Pull request body")
-	cmd.Flags().StringVar(&template, "template", "", "Use a specific template file")
-	cmd.Flags().BoolVarP(&draft, "draft", "d", false, "Create as draft pull request")
-	cmd.Flags().StringVar(&base, "base", "", "Base branch (default: main/master)")
-	cmd.Flags().StringVar(&head, "head", "", "Head branch (default: current branch)")
-	cmd.Flags().BoolVarP(&web, "web", "w", false, "Open in web browser")
-	cmd.Flags().StringSliceVarP(&reviewers, "reviewer", "r", nil, "Request reviewers (comma-separated)")
-	cmd.Flags().StringSliceVarP(&assignees, "assignee", "a", nil, "Assign users (comma-separated)")
-	cmd.Flags().StringSliceVarP(&labels, "label", "l", nil, "Add labels (comma-separated)")
-	cmd.Flags().BoolVar(&refresh, "refresh", false, "Refresh template cache")
-
-	return cmd
-}
-
-type createOpts struct {
-	title     string
-	body      string
-	template  string
-	draft     bool
-	base      string
-	head      string
-	web       bool
-	reviewers []string
-	assignees []string
-	labels    []string
-	refresh   bool
-}
-
-func runCreate(out *output.Writer, opts createOpts) error {
-	// If template is specified, load it
-	if opts.template != "" {
-		content, err := loadTemplate(out, opts.template, opts.refresh)
-		if err != nil {
-			return err
-		}
-
-		// Use template content if body is empty
-		if opts.body == "" {
-			opts.body = content
-		}
-	}
-
-	// Build gh pr create command
-	ghArgs := []string{"pr", "create"}
-
-	if opts.title != "" {
-		ghArgs = append(ghArgs, "--title", opts.title)
-	}
-
-	if opts.body != "" {
-		ghArgs = append(ghArgs, "--body", opts.body)
-	}
-
-	if opts.draft {
-		ghArgs = append(ghArgs, "--draft")
-	}
-
-	if opts.base != "" {
-		ghArgs = append(ghArgs, "--base", opts.base)
-	}
-
-	if opts.head != "" {
-		ghArgs = append(ghArgs, "--head", opts.head)
-	}
-
-	if opts.web {
-		ghArgs = append(ghArgs, "--web")
-	}
-
-	for _, reviewer := range opts.reviewers {
-		ghArgs = append(ghArgs, "--reviewer", reviewer)
-	}
-
-	for _, assignee := range opts.assignees {
-		ghArgs = append(ghArgs, "--assignee", assignee)
-	}
-
-	for _, label := range opts.labels {
-		ghArgs = append(ghArgs, "--label", label)
-	}
-
-	out.Info("Creating pull request...")
-
-	// Execute gh command
-	cmd := exec.Command("gh", ghArgs...)
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-	cmd.Stdin = os.Stdin
-
-	if err := cmd.Run(); err != nil {
-		return fmt.Errorf("gh pr create failed: %w", err)
-	}
-
-	return nil
-}
-
-func loadTemplate(out *output.Writer, templatePath string, refresh bool) (string, error) {
-	finder, err := templates.NewFinder()
-	if err != nil {
-		return "", err
-	}
-
-	// If template path is just a name, try to find it
-	if !strings.Contains(templatePath, "/") {
-		out.Info("Searching for template: %s", templatePath)
-
-		tmplList, err := finder.Find(refresh)
-		if err != nil {
-			return "", fmt.Errorf("failed to find templates: %w", err)
-		}
-
-		for _, tmpl := range tmplList {
-			if tmpl.Name == templatePath || tmpl.Path == templatePath {
-				out.Success("Found template: %s", tmpl.Path)
-				return tmpl.Content, nil
-			}
-		}
-
-		return "", fmt.Errorf("template not found: %s", templatePath)
-	}
-
-	// Direct file path
-	return templates.ReadTemplate(templatePath)
-}
tools/gh-pr/cmd/gh-pr/list_templates.go
@@ -1,148 +0,0 @@
-package main
-
-import (
-	"fmt"
-
-	"github.com/spf13/cobra"
-	"github.com/vdemeester/home/tools/gh-pr/internal/output"
-	"github.com/vdemeester/home/tools/gh-pr/internal/templates"
-)
-
-func listTemplatesCmd(out *output.Writer) *cobra.Command {
-	var (
-		refresh bool
-		verbose bool
-	)
-
-	cmd := &cobra.Command{
-		Use:   "list-templates [REPOSITORY]",
-		Short: "List available pull request templates",
-		Long: `List all pull request templates found in the repository.
-
-Templates are cached for one week by default. Use --refresh to bypass
-the cache and search for templates again.
-
-Examples:
-  gh-pr list-templates                    # List templates in current repo
-  gh-pr list-templates tektoncd/pipeline  # List templates from remote repo
-  gh-pr list-templates --verbose          # Show template previews
-  gh-pr list-templates --refresh          # Bypass cache`,
-		RunE: func(cmd *cobra.Command, args []string) error {
-			var repo string
-			if len(args) > 0 {
-				repo = args[0]
-			}
-			return runListTemplates(out, repo, refresh, verbose)
-		},
-	}
-
-	cmd.Flags().BoolVar(&refresh, "refresh", false, "Refresh template cache")
-	cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show template content preview")
-
-	return cmd
-}
-
-func runListTemplates(out *output.Writer, repo string, refresh, verbose bool) error {
-	finder, err := templates.NewFinder()
-	if err != nil {
-		return fmt.Errorf("failed to create template finder: %w", err)
-	}
-
-	var tmplList []templates.Template
-
-	if repo != "" {
-		// Search in remote repository
-		if refresh {
-			out.Info("Fetching templates from %s (bypassing cache)...", repo)
-		} else {
-			out.Info("Searching for templates in %s...", repo)
-		}
-
-		tmplList, err = finder.FindInRepo(repo, refresh)
-		if err != nil {
-			return fmt.Errorf("failed to find templates in %s: %w", repo, err)
-		}
-	} else {
-		// Search in current repository
-		if refresh {
-			out.Info("Refreshing template cache...")
-		}
-
-		tmplList, err = finder.Find(refresh)
-		if err != nil {
-			return fmt.Errorf("failed to find templates: %w", err)
-		}
-	}
-
-	if len(tmplList) == 0 {
-		if repo != "" {
-			out.Warning("No pull request templates found in %s.", repo)
-		} else {
-			out.Warning("No pull request templates found.")
-		}
-		out.Println("")
-		out.Println("Templates are typically located in:")
-		out.Println("  - .github/PULL_REQUEST_TEMPLATE.md")
-		out.Println("  - .github/PULL_REQUEST_TEMPLATE/")
-		out.Println("  - docs/PULL_REQUEST_TEMPLATE.md")
-		return nil
-	}
-
-	if repo != "" {
-		out.Success("Found %d pull request template(s) in %s:", len(tmplList), repo)
-	} else {
-		out.Success("Found %d pull request template(s):", len(tmplList))
-	}
-	out.Println("")
-
-	for i, tmpl := range tmplList {
-		out.Println("%d. %s", i+1, tmpl.Name)
-		out.Println("   Path: %s", tmpl.Path)
-
-		if verbose {
-			// Show first few lines of template
-			lines := splitLines(tmpl.Content, 5)
-			out.Println("   Preview:")
-			for _, line := range lines {
-				out.Println("     %s", line)
-			}
-			if len(lines) == 5 {
-				out.Println("     ...")
-			}
-		}
-
-		if i < len(tmplList)-1 {
-			out.Println("")
-		}
-	}
-
-	out.Println("")
-	out.Info("Use 'gh-pr create --template <name>' to create a PR with a template")
-
-	return nil
-}
-
-func splitLines(content string, max int) []string {
-	lines := []string{}
-	current := ""
-
-	for i, char := range content {
-		if char == '\n' {
-			lines = append(lines, current)
-			current = ""
-
-			if len(lines) >= max {
-				break
-			}
-		} else {
-			current += string(char)
-		}
-
-		// Handle last line
-		if i == len(content)-1 && current != "" {
-			lines = append(lines, current)
-		}
-	}
-
-	return lines
-}
tools/gh-pr/cmd/gh-pr/main.go
@@ -1,54 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"os"
-
-	"github.com/spf13/cobra"
-	"github.com/vdemeester/home/tools/gh-pr/internal/output"
-)
-
-var version = "0.1.0"
-
-func main() {
-	if err := rootCmd().Execute(); err != nil {
-		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
-		os.Exit(1)
-	}
-}
-
-func rootCmd() *cobra.Command {
-	out := output.Default()
-
-	cmd := &cobra.Command{
-		Use:   "gh-pr",
-		Short: "GitHub Pull Request management tool",
-		Long: `A comprehensive tool for managing GitHub pull requests.
-
-Combines PR creation with template support, workflow management,
-and conflict resolution in a single command-line interface.`,
-		SilenceUsage:  true,
-		SilenceErrors: true,
-	}
-
-	cmd.AddCommand(versionCmd())
-	cmd.AddCommand(createCmd(out))
-	cmd.AddCommand(listTemplatesCmd(out))
-	cmd.AddCommand(restartFailedCmd(out))
-	cmd.AddCommand(resolveConflictsCmd(out))
-	cmd.AddCommand(commentCmd(out))
-	cmd.AddCommand(approveCmd(out))
-	cmd.AddCommand(cleanupCmd(out))
-
-	return cmd
-}
-
-func versionCmd() *cobra.Command {
-	return &cobra.Command{
-		Use:   "version",
-		Short: "Print version information",
-		Run: func(cmd *cobra.Command, args []string) {
-			fmt.Printf("gh-pr version %s\n", version)
-		},
-	}
-}
tools/gh-pr/cmd/gh-pr/resolve_conflicts.go
@@ -1,244 +0,0 @@
-package main
-
-import (
-	"bufio"
-	"fmt"
-	"os"
-	"os/exec"
-	"strings"
-
-	"github.com/spf13/cobra"
-	"github.com/vdemeester/home/tools/gh-pr/internal/conflicts"
-	"github.com/vdemeester/home/tools/gh-pr/internal/output"
-)
-
-func resolveConflictsCmd(out *output.Writer) *cobra.Command {
-	var (
-		worktreeDir string
-		noWorktree  bool
-		noPush      bool
-		org         string
-		author      string
-	)
-
-	cmd := &cobra.Command{
-		Use:   "resolve-conflicts [REPOSITORY[#PR_NUMBER]]",
-		Short: "Resolve merge conflicts in pull requests",
-		Long: `List pull requests with merge conflicts and resolve them interactively.
-
-This command helps you resolve merge conflicts in pull requests by:
-  - Fetching the PR branch
-  - Creating a worktree or using existing repo
-  - Performing rebase against the base branch
-  - Launching conflict resolution tools (emacs ediff or git mergetool)
-  - Force-pushing resolved changes (optional)
-
-Examples:
-  gh-pr resolve-conflicts                 # Search for your conflicting PRs
-  gh-pr resolve-conflicts owner/repo#123  # Resolve specific PR
-  gh-pr resolve-conflicts -o tektoncd     # Filter by organization
-  gh-pr resolve-conflicts -n              # Use existing repo, no worktree
-  gh-pr resolve-conflicts -N              # Don't auto-push after resolution`,
-		RunE: func(cmd *cobra.Command, args []string) error {
-			var repo, prNumber string
-
-			if len(args) > 0 {
-				arg := args[0]
-				if strings.Contains(arg, "#") {
-					parts := strings.SplitN(arg, "#", 2)
-					repo = parts[0]
-					prNumber = parts[1]
-				} else {
-					repo = arg
-				}
-			}
-
-			return runResolveConflicts(out, resolveConflictsOpts{
-				worktreeDir: worktreeDir,
-				useWorktree: !noWorktree,
-				autoPush:    !noPush,
-				org:         org,
-				author:      author,
-				repo:        repo,
-				prNumber:    prNumber,
-			})
-		},
-	}
-
-	cmd.Flags().StringVarP(&worktreeDir, "worktree", "w", "/tmp/gh-resolve-conflicts-worktrees", "Create worktrees in DIR")
-	cmd.Flags().BoolVarP(&noWorktree, "no-worktree", "n", false, "Use existing repo instead of worktrees")
-	cmd.Flags().BoolVarP(&noPush, "no-push", "N", false, "Do NOT auto-push after resolution")
-	cmd.Flags().StringVarP(&org, "org", "o", "", "Filter PRs by organization")
-	cmd.Flags().StringVarP(&author, "author", "a", "@me", "Filter PRs by author")
-
-	return cmd
-}
-
-type resolveConflictsOpts struct {
-	worktreeDir string
-	useWorktree bool
-	autoPush    bool
-	org         string
-	author      string
-	repo        string
-	prNumber    string
-}
-
-func runResolveConflicts(out *output.Writer, opts resolveConflictsOpts) error {
-	resolver := conflicts.NewResolver(out, opts.worktreeDir, opts.useWorktree, opts.autoPush)
-
-	// If specific PR is provided, resolve it directly
-	if opts.prNumber != "" {
-		if opts.repo == "" {
-			return fmt.Errorf("repository must be specified when using #PR_NUMBER")
-		}
-
-		pr, err := resolver.FindConflictingPR(opts.repo, opts.prNumber)
-		if err != nil {
-			return err
-		}
-
-		out.Success("PR #%d: %s", pr.Number, pr.Title)
-		out.Println("")
-
-		return resolver.ResolvePR(opts.repo, pr)
-	}
-
-	// Interactive mode: search for conflicting PRs
-	prs, err := resolver.FindConflictingPRs(opts.org, opts.author)
-	if err != nil {
-		return err
-	}
-
-	if len(prs) == 0 {
-		out.Success("No pull requests with merge conflicts found!")
-		return nil
-	}
-
-	out.Success("Found %d pull request(s) with merge conflicts", len(prs))
-
-	// Check if fzf is available
-	if _, err := exec.LookPath("fzf"); err != nil {
-		return fmt.Errorf("fzf is required but not found in PATH: %w", err)
-	}
-
-	// Build fzf input with formatted PR information
-	var fzfInput strings.Builder
-	prMap := make(map[int]*conflicts.PRInfo) // Map PR number to info for later lookup
-
-	for _, pr := range prs {
-		prMap[pr.Number] = &pr
-		// Format: "#123  Title here (@author) [repo]"
-		repo := pr.HeadRepository.NameWithOwner
-		fzfInput.WriteString(fmt.Sprintf("#%-6d %s (@%s) [%s]\n",
-			pr.Number, pr.Title, pr.Author.Login, repo))
-	}
-
-	// Build preview command for fzf
-	// We need to extract the repo from the selection and fetch PR details
-	previewCmd := `echo {} | awk '{print $1, $(NF)}' | sed 's/\[//;s/\]//' | \
-		xargs -I{} sh -c 'PR=$(echo {} | cut -d" " -f1); REPO=$(echo {} | cut -d" " -f2); \
-		gh pr view $PR -R $REPO --json number,title,author,headRefName,baseRefName,mergeable,statusCheckRollup 2>/dev/null | \
-		jq -r "\"# PR \" + (.number | tostring) + \": \" + .title,
-		       \"\",
-		       \"Author: @\" + .author.login,
-		       \"\",
-		       \"## Branches:\",
-		       \"\",
-		       \"  \" + .headRefName + \" → \" + .baseRefName,
-		       \"\",
-		       \"## Merge Status: \" + (.mergeable // \"unknown\"),
-		       \"\",
-		       \"## Status Checks:\",
-		       \"\",
-		       (if (.statusCheckRollup // [] | length) == 0 then \"  (No status checks)\"
-		        else (.statusCheckRollup | map(
-		         \"  \" + (
-		           if .conclusion == \"SUCCESS\" then \"✓\"
-		           elif .conclusion == \"FAILURE\" then \"✗\"
-		           elif .conclusion == \"PENDING\" then \"●\"
-		           elif .conclusion == \"SKIPPED\" then \"○\"
-		           else \"?\"
-		           end
-		         ) + \" \" + .name + \" (\" + (.conclusion // \"unknown\") + \")\"
-		       ) | join(\"\\n\"))
-		        end)"'`
-
-	// Use fzf for multi-select with preview
-	out.Info("Select pull requests to resolve (use Tab to select multiple, Enter to confirm)...")
-	fzfCmd := exec.Command("fzf",
-		"--multi",
-		"--ansi",
-		"--header", "Select PRs to resolve conflicts (Tab: select, Enter: confirm)",
-		"--preview", previewCmd,
-		"--preview-window", "right:60%:wrap",
-	)
-	fzfCmd.Stdin = strings.NewReader(fzfInput.String())
-	fzfCmd.Stderr = os.Stderr
-
-	selectedOutput, err := fzfCmd.Output()
-	if err != nil {
-		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 130 {
-			// User cancelled with Ctrl+C
-			out.Info("Selection cancelled.")
-			return nil
-		}
-		return fmt.Errorf("fzf selection failed: %w", err)
-	}
-
-	selectedPRs := strings.TrimSpace(string(selectedOutput))
-	if selectedPRs == "" {
-		out.Info("No pull requests selected.")
-		return nil
-	}
-
-	// Extract PR numbers from selected lines
-	selectedPRNumbers := []int{}
-	scanner := bufio.NewScanner(strings.NewReader(selectedPRs))
-	for scanner.Scan() {
-		line := scanner.Text()
-		// Extract PR number from format "#123  Title..."
-		if strings.HasPrefix(line, "#") {
-			var prNum int
-			if _, err := fmt.Sscanf(line, "#%d", &prNum); err == nil {
-				selectedPRNumbers = append(selectedPRNumbers, prNum)
-			}
-		}
-	}
-
-	if len(selectedPRNumbers) == 0 {
-		out.Warning("No valid PR numbers found in selection.")
-		return nil
-	}
-
-	out.Success("Selected %d pull request(s)", len(selectedPRNumbers))
-	out.Println("")
-
-	// Resolve selected PRs
-	for _, prNum := range selectedPRNumbers {
-		pr, ok := prMap[prNum]
-		if !ok {
-			out.Warning("PR #%d not found in the list, skipping...", prNum)
-			continue
-		}
-
-		// Determine repository from PR
-		repo := pr.HeadRepository.NameWithOwner
-		if pr.IsCrossRepository {
-			out.Warning("Skipping cross-repository PR #%d (requires manual handling)", prNum)
-			continue
-		}
-
-		if err := resolver.ResolvePR(repo, pr); err != nil {
-			out.Error("Failed to resolve PR #%d: %v", prNum, err)
-			out.Println("")
-			continue
-		}
-
-		out.Success("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
-		out.Println("")
-	}
-
-	out.Success("Done!")
-	return nil
-}
tools/gh-pr/cmd/gh-pr/restart_failed.go
@@ -1,379 +0,0 @@
-package main
-
-import (
-	"bufio"
-	"encoding/json"
-	"fmt"
-	"os"
-	"os/exec"
-	"strings"
-
-	"github.com/spf13/cobra"
-	"github.com/vdemeester/home/tools/gh-pr/internal/output"
-)
-
-func restartFailedCmd(out *output.Writer) *cobra.Command {
-	var (
-		ignorePatterns []string
-		labels         []string
-		repo           string
-		prNumber       string
-	)
-
-	cmd := &cobra.Command{
-		Use:   "restart-failed [REPOSITORY[#PR_NUMBER]]",
-		Short: "Restart failed workflow runs on pull requests",
-		Long: `List pull requests with failed checks and restart selected workflows.
-
-By default, "Label Checker" workflows are ignored. Use --ignore to add more patterns.
-
-Examples:
-  gh-pr restart-failed                     # Interactive mode
-  gh-pr restart-failed owner/repo#123      # Restart specific PR
-  gh-pr restart-failed --ignore build      # Ignore "build" workflows
-  gh-pr restart-failed --label bug         # Filter by label`,
-		RunE: func(cmd *cobra.Command, args []string) error {
-			// Parse repository argument
-			if len(args) > 0 {
-				arg := args[0]
-				if strings.Contains(arg, "#") {
-					parts := strings.SplitN(arg, "#", 2)
-					repo = parts[0]
-					prNumber = parts[1]
-				} else {
-					repo = arg
-				}
-			}
-
-			return runRestartFailed(out, restartFailedOpts{
-				ignorePatterns: append([]string{"Label Checker"}, ignorePatterns...),
-				labels:         labels,
-				repo:           repo,
-				prNumber:       prNumber,
-			})
-		},
-	}
-
-	cmd.Flags().StringSliceVarP(&ignorePatterns, "ignore", "i", nil, "Ignore workflows matching pattern")
-	cmd.Flags().StringSliceVarP(&labels, "label", "l", nil, "Filter PRs by label")
-
-	return cmd
-}
-
-type restartFailedOpts struct {
-	ignorePatterns []string
-	labels         []string
-	repo           string
-	prNumber       string
-}
-
-type prInfo struct {
-	Number            int                    `json:"number"`
-	Title             string                 `json:"title"`
-	HeadRefName       string                 `json:"headRefName"`
-	Author            map[string]interface{} `json:"author"`
-	StatusCheckRollup []checkStatus          `json:"statusCheckRollup"`
-}
-
-type checkStatus struct {
-	Name       string `json:"name"`
-	Conclusion string `json:"conclusion"`
-}
-
-type workflowRun struct {
-	DatabaseID int    `json:"databaseId"`
-	Name       string `json:"name"`
-	Conclusion string `json:"conclusion"`
-	Status     string `json:"status"`
-	Event      string `json:"event"`
-}
-
-func runRestartFailed(out *output.Writer, opts restartFailedOpts) error {
-	// Show what we're ignoring
-	if len(opts.ignorePatterns) > 0 {
-		out.Warning("Ignoring workflows matching: %s", strings.Join(opts.ignorePatterns, ", "))
-	}
-
-	// If specific PR is provided, restart it directly
-	if opts.prNumber != "" {
-		return restartSpecificPR(out, opts)
-	}
-
-	// Interactive mode: list and select PRs
-	return restartInteractive(out, opts)
-}
-
-func restartSpecificPR(out *output.Writer, opts restartFailedOpts) error {
-	out.Info("Fetching PR #%s...", opts.prNumber)
-
-	// Build gh command
-	args := []string{"pr", "view", opts.prNumber}
-	if opts.repo != "" {
-		args = append(args, "-R", opts.repo)
-	}
-	args = append(args, "--json", "number,title,headRefName,author")
-
-	cmd := exec.Command("gh", args...)
-	output, err := cmd.Output()
-	if err != nil {
-		return fmt.Errorf("failed to fetch PR: %w", err)
-	}
-
-	var pr prInfo
-	if err := json.Unmarshal(output, &pr); err != nil {
-		return fmt.Errorf("failed to parse PR info: %w", err)
-	}
-
-	out.Success("PR #%d: %s", pr.Number, pr.Title)
-
-	return restartPRWorkflows(out, opts, pr.Number, pr.HeadRefName)
-}
-
-func restartInteractive(out *output.Writer, opts restartFailedOpts) error {
-	// Check if fzf is available
-	if _, err := exec.LookPath("fzf"); err != nil {
-		return fmt.Errorf("fzf is required but not found in PATH: %w", err)
-	}
-
-	out.Info("Fetching pull requests...")
-
-	// Build gh pr list command
-	args := []string{"pr", "list"}
-	if opts.repo != "" {
-		args = append(args, "-R", opts.repo)
-	}
-	for _, label := range opts.labels {
-		args = append(args, "--label", label)
-	}
-	args = append(args, "--json", "number,title,headRefName,author,statusCheckRollup", "--limit", "100")
-
-	cmd := exec.Command("gh", args...)
-	output, err := cmd.Output()
-	if err != nil {
-		return fmt.Errorf("failed to list PRs: %w", err)
-	}
-
-	var prs []prInfo
-	if err := json.Unmarshal(output, &prs); err != nil {
-		return fmt.Errorf("failed to parse PRs: %w", err)
-	}
-
-	// Filter PRs with failed checks
-	type failedPRInfo struct {
-		pr          prInfo
-		failedCount int
-		author      string
-	}
-
-	failedPRs := []failedPRInfo{}
-	for _, pr := range prs {
-		failedCount := 0
-		for _, check := range pr.StatusCheckRollup {
-			if check.Conclusion == "FAILURE" || check.Conclusion == "TIMED_OUT" ||
-				check.Conclusion == "STARTUP_FAILURE" || check.Conclusion == "ACTION_REQUIRED" {
-				failedCount++
-			}
-		}
-		if failedCount > 0 {
-			author := "unknown"
-			if login, ok := pr.Author["login"].(string); ok {
-				author = login
-			}
-			failedPRs = append(failedPRs, failedPRInfo{
-				pr:          pr,
-				failedCount: failedCount,
-				author:      author,
-			})
-		}
-	}
-
-	if len(failedPRs) == 0 {
-		out.Success("No pull requests with failed checks found!")
-		return nil
-	}
-
-	out.Success("Found %d pull request(s) with failed checks", len(failedPRs))
-
-	// Build fzf input with formatted PR information
-	var fzfInput strings.Builder
-	prMap := make(map[int]failedPRInfo) // Map PR number to info for later lookup
-
-	for _, fpr := range failedPRs {
-		prMap[fpr.pr.Number] = fpr
-		// Format: "#123  Title here (@author) - 2 failed"
-		fzfInput.WriteString(fmt.Sprintf("#%-6d %s (@%s) - %d failed\n",
-			fpr.pr.Number, fpr.pr.Title, fpr.author, fpr.failedCount))
-	}
-
-	// Build preview command for fzf
-	repoFlag := ""
-	if opts.repo != "" {
-		repoFlag = fmt.Sprintf("-R %s", opts.repo)
-	}
-
-	previewCmd := fmt.Sprintf(`gh pr view {1} %s --json number,title,author,statusCheckRollup | \
-		jq -r '"# PR " + (.number | tostring) + ": " + .title,
-		       "",
-		       "Author: @" + .author.login,
-		       "",
-		       "## Status Checks:",
-		       "",
-		       (.statusCheckRollup // [] | map(
-		         "  " + (
-		           if .conclusion == "SUCCESS" then "✓"
-		           elif .conclusion == "FAILURE" then "✗"
-		           elif .conclusion == "PENDING" then "●"
-		           elif .conclusion == "SKIPPED" then "○"
-		           else "?"
-		           end
-		         ) + " " + .name + " (" + (.conclusion // "unknown") + ")"
-		       ) | join("\n"))'`, repoFlag)
-
-	// Use fzf for multi-select with preview
-	out.Info("Select pull requests to restart (use Tab to select multiple, Enter to confirm)...")
-	fzfCmd := exec.Command("fzf",
-		"--multi",
-		"--ansi",
-		"--header", "Select PRs to restart workflows (Tab: select, Enter: confirm)",
-		"--preview", previewCmd,
-		"--preview-window", "right:60%:wrap",
-	)
-	fzfCmd.Stdin = strings.NewReader(fzfInput.String())
-	fzfCmd.Stderr = os.Stderr
-
-	selectedOutput, err := fzfCmd.Output()
-	if err != nil {
-		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 130 {
-			// User cancelled with Ctrl+C
-			out.Info("Selection cancelled.")
-			return nil
-		}
-		return fmt.Errorf("fzf selection failed: %w", err)
-	}
-
-	selectedPRs := strings.TrimSpace(string(selectedOutput))
-	if selectedPRs == "" {
-		out.Info("No pull requests selected.")
-		return nil
-	}
-
-	// Extract PR numbers from selected lines
-	selectedPRNumbers := []int{}
-	scanner := bufio.NewScanner(strings.NewReader(selectedPRs))
-	for scanner.Scan() {
-		line := scanner.Text()
-		// Extract PR number from format "#123  Title..."
-		if strings.HasPrefix(line, "#") {
-			var prNum int
-			if _, err := fmt.Sscanf(line, "#%d", &prNum); err == nil {
-				selectedPRNumbers = append(selectedPRNumbers, prNum)
-			}
-		}
-	}
-
-	if len(selectedPRNumbers) == 0 {
-		out.Warning("No valid PR numbers found in selection.")
-		return nil
-	}
-
-	out.Success("Selected %d pull request(s)", len(selectedPRNumbers))
-	out.Println("")
-
-	// Restart workflows for selected PRs
-	for _, prNum := range selectedPRNumbers {
-		fpr, ok := prMap[prNum]
-		if !ok {
-			out.Warning("PR #%d not found in the list, skipping...", prNum)
-			continue
-		}
-
-		out.Info("PR #%d: %s", fpr.pr.Number, fpr.pr.Title)
-		if err := restartPRWorkflows(out, opts, fpr.pr.Number, fpr.pr.HeadRefName); err != nil {
-			out.Error("Failed to restart workflows: %v", err)
-		}
-		out.Println("")
-	}
-
-	out.Success("Done!")
-	return nil
-}
-
-func restartPRWorkflows(out *output.Writer, opts restartFailedOpts, prNumber int, branch string) error {
-	// Get failed workflow runs for this PR
-	args := []string{"run", "list", "--branch", branch}
-	if opts.repo != "" {
-		args = append(args, "-R", opts.repo)
-	}
-	args = append(args, "--json", "databaseId,name,conclusion,status,event", "--limit", "50")
-
-	cmd := exec.Command("gh", args...)
-	output, err := cmd.Output()
-	if err != nil {
-		return fmt.Errorf("failed to list workflow runs: %w", err)
-	}
-
-	var runs []workflowRun
-	if err := json.Unmarshal(output, &runs); err != nil {
-		return fmt.Errorf("failed to parse workflow runs: %w", err)
-	}
-
-	// Filter failed runs
-	failedRuns := []workflowRun{}
-	for _, run := range runs {
-		// Check if it's a PR event and failed
-		if run.Event != "pull_request" {
-			continue
-		}
-
-		if run.Conclusion != "failure" && run.Conclusion != "timed_out" &&
-			run.Conclusion != "startup_failure" && run.Conclusion != "action_required" {
-			continue
-		}
-
-		// Check ignore patterns
-		ignored := false
-		for _, pattern := range opts.ignorePatterns {
-			if strings.Contains(run.Name, pattern) {
-				ignored = true
-				break
-			}
-		}
-
-		if !ignored {
-			failedRuns = append(failedRuns, run)
-		}
-	}
-
-	if len(failedRuns) == 0 {
-		out.Warning("  No failed workflow runs found (may have been restarted already)")
-		return nil
-	}
-
-	out.Info("  Restarting %d failed workflow(s):", len(failedRuns))
-
-	// Restart each failed workflow
-	for _, run := range failedRuns {
-		out.Print("  → Restarting: %s (%s)... ", run.Name, run.Conclusion)
-
-		rerunArgs := []string{"run", "rerun", fmt.Sprintf("%d", run.DatabaseID), "--failed"}
-		if opts.repo != "" {
-			rerunArgs = append(rerunArgs, "-R", opts.repo)
-		}
-
-		rerunCmd := exec.Command("gh", rerunArgs...)
-		rerunOutput, err := rerunCmd.CombinedOutput()
-		outputStr := strings.TrimSpace(string(rerunOutput))
-
-		if err != nil || strings.Contains(outputStr, "error") {
-			if strings.Contains(outputStr, "created over a month ago") {
-				out.Warning("⚠ Cannot restart: workflow run is too old (>1 month)")
-			} else {
-				out.Error("✗ Failed: %s", outputStr)
-			}
-		} else {
-			out.Success("✓")
-		}
-	}
-
-	return nil
-}
tools/gh-pr/internal/cache/cache.go
@@ -1,129 +0,0 @@
-package cache
-
-import (
-	"encoding/json"
-	"os"
-	"path/filepath"
-	"time"
-)
-
-const (
-	// DefaultTTL is the default time-to-live for cache entries (1 week)
-	DefaultTTL = 7 * 24 * time.Hour
-
-	// CacheDir is the directory where cache files are stored
-	cacheDir = ".cache/gh-pr"
-)
-
-// Entry represents a cached item with expiration
-type Entry struct {
-	Data      interface{} `json:"data"`
-	ExpiresAt time.Time   `json:"expires_at"`
-}
-
-// Cache handles caching of data with TTL support
-type Cache struct {
-	baseDir string
-	ttl     time.Duration
-}
-
-// New creates a new Cache instance
-func New(ttl time.Duration) (*Cache, error) {
-	homeDir, err := os.UserHomeDir()
-	if err != nil {
-		return nil, err
-	}
-
-	baseDir := filepath.Join(homeDir, cacheDir)
-	if err := os.MkdirAll(baseDir, 0755); err != nil {
-		return nil, err
-	}
-
-	if ttl == 0 {
-		ttl = DefaultTTL
-	}
-
-	return &Cache{
-		baseDir: baseDir,
-		ttl:     ttl,
-	}, nil
-}
-
-// Get retrieves a value from cache
-// Returns nil if not found or expired
-func (c *Cache) Get(key string, dest interface{}) error {
-	filePath := filepath.Join(c.baseDir, key+".json")
-
-	data, err := os.ReadFile(filePath)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return nil
-		}
-		return err
-	}
-
-	var entry Entry
-	if err := json.Unmarshal(data, &entry); err != nil {
-		return err
-	}
-
-	// Check if expired
-	if time.Now().After(entry.ExpiresAt) {
-		// Clean up expired entry
-		_ = os.Remove(filePath)
-		return nil
-	}
-
-	// Unmarshal the data into the destination
-	dataBytes, err := json.Marshal(entry.Data)
-	if err != nil {
-		return err
-	}
-
-	return json.Unmarshal(dataBytes, dest)
-}
-
-// Set stores a value in cache with the configured TTL
-func (c *Cache) Set(key string, value interface{}) error {
-	entry := Entry{
-		Data:      value,
-		ExpiresAt: time.Now().Add(c.ttl),
-	}
-
-	data, err := json.Marshal(entry)
-	if err != nil {
-		return err
-	}
-
-	filePath := filepath.Join(c.baseDir, key+".json")
-	return os.WriteFile(filePath, data, 0644)
-}
-
-// Delete removes an entry from cache
-func (c *Cache) Delete(key string) error {
-	filePath := filepath.Join(c.baseDir, key+".json")
-	err := os.Remove(filePath)
-	if os.IsNotExist(err) {
-		return nil
-	}
-	return err
-}
-
-// Clear removes all cache entries
-func (c *Cache) Clear() error {
-	entries, err := os.ReadDir(c.baseDir)
-	if err != nil {
-		return err
-	}
-
-	for _, entry := range entries {
-		if !entry.IsDir() {
-			filePath := filepath.Join(c.baseDir, entry.Name())
-			if err := os.Remove(filePath); err != nil {
-				return err
-			}
-		}
-	}
-
-	return nil
-}
tools/gh-pr/internal/cache/cache_test.go
@@ -1,117 +0,0 @@
-package cache
-
-import (
-	"os"
-	"path/filepath"
-	"testing"
-	"time"
-)
-
-func TestCache(t *testing.T) {
-	// Create temporary cache directory
-	tmpDir := t.TempDir()
-
-	c := &Cache{
-		baseDir: tmpDir,
-		ttl:     1 * time.Second,
-	}
-
-	t.Run("Set and Get", func(t *testing.T) {
-		type testData struct {
-			Name  string
-			Value int
-		}
-
-		original := testData{Name: "test", Value: 42}
-
-		if err := c.Set("test-key", original); err != nil {
-			t.Fatalf("Set failed: %v", err)
-		}
-
-		var retrieved testData
-		if err := c.Get("test-key", &retrieved); err != nil {
-			t.Fatalf("Get failed: %v", err)
-		}
-
-		if retrieved.Name != original.Name || retrieved.Value != original.Value {
-			t.Errorf("Retrieved data mismatch: got %+v, want %+v", retrieved, original)
-		}
-	})
-
-	t.Run("Get non-existent key", func(t *testing.T) {
-		var data string
-		if err := c.Get("non-existent", &data); err != nil {
-			t.Fatalf("Get should not error on non-existent key: %v", err)
-		}
-		if data != "" {
-			t.Errorf("Expected empty data for non-existent key, got: %s", data)
-		}
-	})
-
-	t.Run("Expiration", func(t *testing.T) {
-		if err := c.Set("expire-test", "value"); err != nil {
-			t.Fatalf("Set failed: %v", err)
-		}
-
-		// Wait for expiration
-		time.Sleep(2 * time.Second)
-
-		var data string
-		if err := c.Get("expire-test", &data); err != nil {
-			t.Fatalf("Get failed: %v", err)
-		}
-
-		if data != "" {
-			t.Errorf("Expected empty data after expiration, got: %s", data)
-		}
-
-		// Verify file was cleaned up
-		filePath := filepath.Join(tmpDir, "expire-test.json")
-		if _, err := os.Stat(filePath); !os.IsNotExist(err) {
-			t.Error("Expected cache file to be deleted after expiration")
-		}
-	})
-
-	t.Run("Delete", func(t *testing.T) {
-		if err := c.Set("delete-test", "value"); err != nil {
-			t.Fatalf("Set failed: %v", err)
-		}
-
-		if err := c.Delete("delete-test"); err != nil {
-			t.Fatalf("Delete failed: %v", err)
-		}
-
-		var data string
-		if err := c.Get("delete-test", &data); err != nil {
-			t.Fatalf("Get failed: %v", err)
-		}
-
-		if data != "" {
-			t.Errorf("Expected empty data after delete, got: %s", data)
-		}
-	})
-
-	t.Run("Clear", func(t *testing.T) {
-		// Set multiple entries
-		for i := 0; i < 3; i++ {
-			key := filepath.Join("clear-test", string(rune('a'+i)))
-			if err := c.Set(key, i); err != nil {
-				t.Fatalf("Set failed: %v", err)
-			}
-		}
-
-		if err := c.Clear(); err != nil {
-			t.Fatalf("Clear failed: %v", err)
-		}
-
-		// Verify all entries are gone
-		entries, err := os.ReadDir(tmpDir)
-		if err != nil {
-			t.Fatalf("ReadDir failed: %v", err)
-		}
-
-		if len(entries) != 0 {
-			t.Errorf("Expected 0 cache entries after clear, got %d", len(entries))
-		}
-	})
-}
tools/gh-pr/internal/conflicts/conflicts.go
@@ -1,467 +0,0 @@
-package conflicts
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"strings"
-
-	"github.com/vdemeester/home/tools/gh-pr/internal/output"
-)
-
-// Resolver handles merge conflict resolution
-type Resolver struct {
-	out         *output.Writer
-	worktreeDir string
-	useWorktree bool
-	autoPush    bool
-}
-
-// NewResolver creates a new conflict resolver
-func NewResolver(out *output.Writer, worktreeDir string, useWorktree, autoPush bool) *Resolver {
-	return &Resolver{
-		out:         out,
-		worktreeDir: worktreeDir,
-		useWorktree: useWorktree,
-		autoPush:    autoPush,
-	}
-}
-
-// PRInfo contains information about a pull request
-type PRInfo struct {
-	Number            int    `json:"number"`
-	Title             string `json:"title"`
-	HeadRefName       string `json:"headRefName"`
-	BaseRefName       string `json:"baseRefName"`
-	Mergeable         string `json:"mergeable"`
-	URL               string `json:"url"`
-	IsCrossRepository bool   `json:"isCrossRepository"`
-	HeadRepository    struct {
-		Name          string `json:"name"`
-		NameWithOwner string `json:"nameWithOwner"`
-	} `json:"headRepository"`
-	HeadRepositoryOwner struct {
-		Login string `json:"login"`
-	} `json:"headRepositoryOwner"`
-	Author struct {
-		Login string `json:"login"`
-	} `json:"author"`
-}
-
-// FindConflictingPR finds a specific PR and checks if it has conflicts
-func (r *Resolver) FindConflictingPR(repo, prNumber string) (*PRInfo, error) {
-	r.out.Info("Fetching PR #%s from %s...", prNumber, repo)
-
-	args := []string{"pr", "view", prNumber, "-R", repo,
-		"--json", "number,title,headRefName,baseRefName,author,mergeable,url,isCrossRepository,headRepository,headRepositoryOwner"}
-
-	cmd := exec.Command("gh", args...)
-	output, err := cmd.Output()
-	if err != nil {
-		return nil, fmt.Errorf("failed to fetch PR: %w", err)
-	}
-
-	var pr PRInfo
-	if err := json.Unmarshal(output, &pr); err != nil {
-		return nil, fmt.Errorf("failed to parse PR info: %w", err)
-	}
-
-	if pr.Mergeable != "CONFLICTING" {
-		return nil, fmt.Errorf("PR #%s does not have merge conflicts (status: %s)", prNumber, pr.Mergeable)
-	}
-
-	return &pr, nil
-}
-
-// FindConflictingPRs searches for all conflicting PRs for a given author/org
-func (r *Resolver) FindConflictingPRs(org, author string) ([]PRInfo, error) {
-	r.out.Info("Searching for conflicting pull requests...")
-
-	// Build search query
-	args := []string{"search", "prs", "--author", author, "--state", "open"}
-	if org != "" {
-		args = append(args, "--owner", org)
-	}
-	args = append(args, "--json", "number,title,repository,url", "--limit", "100")
-
-	cmd := exec.Command("gh", args...)
-	output, err := cmd.Output()
-	if err != nil {
-		return nil, fmt.Errorf("failed to search PRs: %w", err)
-	}
-
-	var searchResults []struct {
-		Number     int    `json:"number"`
-		Title      string `json:"title"`
-		URL        string `json:"url"`
-		Repository struct {
-			NameWithOwner string `json:"nameWithOwner"`
-		} `json:"repository"`
-	}
-
-	if err := json.Unmarshal(output, &searchResults); err != nil {
-		return nil, fmt.Errorf("failed to parse search results: %w", err)
-	}
-
-	if len(searchResults) == 0 {
-		return nil, nil
-	}
-
-	r.out.Info("Checking %d PRs for merge conflicts...", len(searchResults))
-
-	var conflictingPRs []PRInfo
-	for i, result := range searchResults {
-		r.out.Print("\rChecking PR %d/%d...", i+1, len(searchResults))
-
-		// Fetch detailed PR info
-		pr, err := r.FindConflictingPR(result.Repository.NameWithOwner, fmt.Sprintf("%d", result.Number))
-		if err != nil {
-			// Skip non-conflicting PRs
-			continue
-		}
-
-		conflictingPRs = append(conflictingPRs, *pr)
-	}
-
-	fmt.Println() // Newline after progress
-	return conflictingPRs, nil
-}
-
-// ResolvePR resolves conflicts for a single PR
-func (r *Resolver) ResolvePR(repo string, pr *PRInfo) error {
-	r.out.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
-	r.out.Info("Repository: %s", repo)
-	r.out.Info("PR #%d: %s", pr.Number, pr.Title)
-	r.out.Info("Branch: %s -> %s", pr.HeadRefName, pr.BaseRefName)
-	r.out.Info("URL: %s", pr.URL)
-	r.out.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
-	r.out.Println("")
-
-	// Determine fork repository
-	var forkRepo string
-	if pr.IsCrossRepository {
-		forkRepo = pr.HeadRepository.NameWithOwner
-		if forkRepo == "" {
-			forkRepo = fmt.Sprintf("%s/%s", pr.HeadRepositoryOwner.Login, pr.HeadRepository.Name)
-		}
-		r.out.Info("PR is from fork: %s", forkRepo)
-	} else {
-		forkRepo = repo
-		r.out.Info("PR is from branch in same repo")
-	}
-
-	var workDir string
-	var err error
-
-	if r.useWorktree {
-		workDir, err = r.setupWorktree(repo, forkRepo, pr)
-		if err != nil {
-			return err
-		}
-	} else {
-		workDir, err = r.setupExistingRepo(repo, forkRepo, pr)
-		if err != nil {
-			return err
-		}
-	}
-
-	// Change to work directory
-	if err := os.Chdir(workDir); err != nil {
-		return fmt.Errorf("failed to change to work directory: %w", err)
-	}
-
-	// Perform rebase
-	if err := r.performRebase(repo, pr, forkRepo); err != nil {
-		return err
-	}
-
-	// Push changes
-	if r.autoPush {
-		r.out.Info("Force-pushing changes...")
-		cmd := exec.Command("git", "push", "--force-with-lease")
-		cmd.Stdout = os.Stdout
-		cmd.Stderr = os.Stderr
-		if err := cmd.Run(); err != nil {
-			r.out.Error("Failed to push changes")
-			r.out.Warning("You may need to push manually from: %s", workDir)
-			return err
-		}
-		r.out.Success("✓ Changes pushed successfully!")
-	} else {
-		r.out.Warning("Changes not pushed. To push manually:")
-		r.out.Println("  cd %s", workDir)
-		r.out.Println("  git push --force-with-lease")
-	}
-
-	r.out.Println("")
-
-	if r.useWorktree {
-		r.out.Warning("Note: Worktree kept at: %s", workDir)
-		r.out.Warning("To remove: git worktree remove %s", workDir)
-	}
-
-	return nil
-}
-
-func (r *Resolver) setupWorktree(repo, forkRepo string, pr *PRInfo) (string, error) {
-	repoName := strings.ReplaceAll(repo, "/", "-")
-	workDir := filepath.Join(r.worktreeDir, repoName, fmt.Sprintf("pr-%d", pr.Number))
-
-	r.out.Info("Creating worktree at: %s", workDir)
-
-	// Create parent directory
-	if err := os.MkdirAll(filepath.Join(r.worktreeDir, repoName), 0755); err != nil {
-		return "", fmt.Errorf("failed to create worktree directory: %w", err)
-	}
-
-	// Clone/fetch repository
-	repoDir := filepath.Join(r.worktreeDir, repoName, "main")
-	if _, err := os.Stat(repoDir); os.IsNotExist(err) {
-		r.out.Info("Cloning fork: %s...", forkRepo)
-		cmd := exec.Command("gh", "repo", "clone", forkRepo, repoDir, "--", "--bare")
-		if err := cmd.Run(); err != nil {
-			return "", fmt.Errorf("failed to clone repository: %w", err)
-		}
-
-		// Add upstream remote if this is a fork
-		if pr.IsCrossRepository {
-			r.out.Info("Adding upstream remote: %s...", repo)
-			cmd := exec.Command("git", "-C", repoDir, "remote", "add", "upstream", fmt.Sprintf("https://github.com/%s.git", repo))
-			cmd.Run() // Ignore error if already exists
-		}
-	} else {
-		r.out.Info("Fetching latest changes from fork...")
-		cmd := exec.Command("git", "-C", repoDir, "fetch", "origin")
-		if err := cmd.Run(); err != nil {
-			return "", fmt.Errorf("failed to fetch: %w", err)
-		}
-
-		// Ensure upstream exists
-		if pr.IsCrossRepository {
-			cmd := exec.Command("git", "-C", repoDir, "remote", "get-url", "upstream")
-			if err := cmd.Run(); err != nil {
-				r.out.Info("Adding upstream remote: %s...", repo)
-				cmd := exec.Command("git", "-C", repoDir, "remote", "add", "upstream", fmt.Sprintf("https://github.com/%s.git", repo))
-				cmd.Run()
-			}
-		}
-	}
-
-	// Fetch from upstream if fork
-	if pr.IsCrossRepository {
-		r.out.Info("Fetching from upstream: %s...", repo)
-		cmd := exec.Command("git", "-C", repoDir, "fetch", "upstream")
-		if err := cmd.Run(); err != nil {
-			return "", fmt.Errorf("failed to fetch upstream: %w", err)
-		}
-	}
-
-	// Remove existing worktree if present
-	if _, err := os.Stat(workDir); err == nil {
-		r.out.Info("Removing existing worktree...")
-		cmd := exec.Command("git", "-C", repoDir, "worktree", "remove", workDir, "--force")
-		cmd.Run() // Ignore error
-		os.RemoveAll(workDir)
-	}
-
-	// Fetch PR branch
-	r.out.Info("Fetching PR branch: %s...", pr.HeadRefName)
-	cmd := exec.Command("git", "-C", repoDir, "fetch", "origin", fmt.Sprintf("%s:pr-%d", pr.HeadRefName, pr.Number))
-	if err := cmd.Run(); err != nil {
-		return "", fmt.Errorf("failed to fetch PR branch: %w", err)
-	}
-
-	// Create worktree
-	r.out.Info("Creating worktree for branch %s...", pr.HeadRefName)
-	cmd = exec.Command("git", "-C", repoDir, "worktree", "add", workDir, fmt.Sprintf("pr-%d", pr.Number))
-	if err := cmd.Run(); err != nil {
-		return "", fmt.Errorf("failed to create worktree: %w", err)
-	}
-
-	return workDir, nil
-}
-
-func (r *Resolver) setupExistingRepo(repo, forkRepo string, pr *PRInfo) (string, error) {
-	r.out.Info("Using existing repository (no worktree)")
-
-	cwd, err := os.Getwd()
-	if err != nil {
-		return "", err
-	}
-
-	// Verify we're in the correct repository
-	cmd := exec.Command("gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner")
-	output, err := cmd.Output()
-	if err != nil {
-		return "", fmt.Errorf("failed to get current repo: %w", err)
-	}
-
-	currentRepo := strings.TrimSpace(string(output))
-	if currentRepo != forkRepo {
-		return "", fmt.Errorf("current directory is %s, expected %s. Please cd to your fork or use --worktree mode", currentRepo, forkRepo)
-	}
-
-	// Ensure upstream remote exists if fork
-	if pr.IsCrossRepository {
-		cmd := exec.Command("git", "remote", "get-url", "upstream")
-		if err := cmd.Run(); err != nil {
-			r.out.Info("Adding upstream remote: %s...", repo)
-			cmd := exec.Command("git", "remote", "add", "upstream", fmt.Sprintf("https://github.com/%s.git", repo))
-			if err := cmd.Run(); err != nil {
-				return "", fmt.Errorf("failed to add upstream: %w", err)
-			}
-		}
-
-		r.out.Info("Fetching from upstream...")
-		cmd = exec.Command("git", "fetch", "upstream")
-		if err := cmd.Run(); err != nil {
-			return "", fmt.Errorf("failed to fetch upstream: %w", err)
-		}
-	}
-
-	// Checkout PR
-	r.out.Info("Checking out PR #%d...", pr.Number)
-	cmd = exec.Command("gh", "pr", "checkout", fmt.Sprintf("%d", pr.Number), "-R", repo)
-	if err := cmd.Run(); err != nil {
-		return "", fmt.Errorf("failed to checkout PR: %w", err)
-	}
-
-	return cwd, nil
-}
-
-func (r *Resolver) performRebase(repo string, pr *PRInfo, forkRepo string) error {
-	// Determine base remote
-	baseRemote := "origin"
-	if pr.IsCrossRepository {
-		baseRemote = "upstream"
-	}
-
-	// Fetch base branch
-	r.out.Info("Fetching base branch from %s: %s", baseRemote, pr.BaseRefName)
-	cmd := exec.Command("git", "fetch", baseRemote, pr.BaseRefName)
-	if err := cmd.Run(); err != nil {
-		return fmt.Errorf("failed to fetch base branch: %w", err)
-	}
-
-	// Start rebase
-	r.out.Info("Starting rebase onto %s/%s...", baseRemote, pr.BaseRefName)
-	r.out.Println("")
-
-	cmd = exec.Command("git", "rebase", fmt.Sprintf("%s/%s", baseRemote, pr.BaseRefName))
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-
-	if err := cmd.Run(); err == nil {
-		r.out.Success("✓ Rebase completed successfully with no conflicts!")
-		return nil
-	}
-
-	// Conflicts detected
-	r.out.Warning("Conflicts detected. Starting conflict resolution...")
-	r.out.Println("")
-
-	// Get conflicted files
-	cmd = exec.Command("git", "diff", "--name-only", "--diff-filter=U")
-	output, err := cmd.Output()
-	if err != nil {
-		r.out.Error("Failed to get conflicted files")
-		exec.Command("git", "rebase", "--abort").Run()
-		return fmt.Errorf("failed to get conflicted files: %w", err)
-	}
-
-	conflictedFiles := strings.Split(strings.TrimSpace(string(output)), "\n")
-	if len(conflictedFiles) == 0 || conflictedFiles[0] == "" {
-		r.out.Error("Rebase failed but no conflicted files found")
-		exec.Command("git", "rebase", "--abort").Run()
-		return fmt.Errorf("rebase failed with no conflicts")
-	}
-
-	r.out.Info("Conflicted files:")
-	for _, file := range conflictedFiles {
-		r.out.Error("  ✗ %s", file)
-	}
-	r.out.Println("")
-
-	// Resolve conflicts
-	for _, file := range conflictedFiles {
-		if err := r.resolveConflict(file); err != nil {
-			r.out.Error("Failed to resolve conflict in %s: %v", file, err)
-			exec.Command("git", "rebase", "--abort").Run()
-			return fmt.Errorf("conflict resolution failed: %w", err)
-		}
-	}
-
-	// Continue rebase
-	r.out.Info("Continuing rebase...")
-	cmd = exec.Command("git", "rebase", "--continue")
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-	if err := cmd.Run(); err != nil {
-		r.out.Error("Failed to continue rebase")
-		r.out.Warning("You may need to resolve remaining conflicts manually")
-		return fmt.Errorf("failed to continue rebase: %w", err)
-	}
-
-	r.out.Println("")
-	r.out.Success("✓ Conflicts resolved successfully!")
-	r.out.Println("")
-
-	return nil
-}
-
-func (r *Resolver) resolveConflict(file string) error {
-	r.out.Info("Resolving: %s", file)
-
-	// Check if emacs is available
-	if _, err := exec.LookPath("emacs"); err == nil {
-		// Use emacs ediff
-		r.out.Success("Launching emacs ediff for: %s", file)
-
-		// Use git mergetool with emacs
-		cmd := exec.Command("git", "mergetool", "--tool=emerge", file)
-		cmd.Stdin = os.Stdin
-		cmd.Stdout = os.Stdout
-		cmd.Stderr = os.Stderr
-
-		if err := cmd.Run(); err != nil {
-			r.out.Warning("Emacs ediff failed, falling back to default mergetool")
-		} else {
-			// Check if resolved
-			if !r.hasConflictMarkers(file) {
-				cmd := exec.Command("git", "add", file)
-				cmd.Run()
-				return nil
-			}
-		}
-	}
-
-	// Fallback to default git mergetool
-	r.out.Info("Using git mergetool...")
-	cmd := exec.Command("git", "mergetool", file)
-	cmd.Stdin = os.Stdin
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-
-	if err := cmd.Run(); err != nil {
-		return err
-	}
-
-	// Verify resolution
-	if r.hasConflictMarkers(file) {
-		return fmt.Errorf("conflict markers still present in %s", file)
-	}
-
-	// Stage file
-	cmd = exec.Command("git", "add", file)
-	return cmd.Run()
-}
-
-func (r *Resolver) hasConflictMarkers(file string) bool {
-	content, err := os.ReadFile(file)
-	if err != nil {
-		return false
-	}
-	return strings.Contains(string(content), "<<<<<<<")
-}
tools/gh-pr/internal/output/output.go
@@ -1,80 +0,0 @@
-package output
-
-import (
-	"fmt"
-	"io"
-	"os"
-)
-
-// Color codes for terminal output
-const (
-	Red    = "\033[0;31m"
-	Green  = "\033[0;32m"
-	Yellow = "\033[1;33m"
-	Blue   = "\033[0;34m"
-	Reset  = "\033[0m"
-)
-
-// Writer provides colored output methods
-type Writer struct {
-	out    io.Writer
-	err    io.Writer
-	colors bool
-}
-
-// NewWriter creates a new output writer
-func NewWriter(out, err io.Writer, colors bool) *Writer {
-	return &Writer{
-		out:    out,
-		err:    err,
-		colors: colors,
-	}
-}
-
-// Default creates a writer that outputs to stdout/stderr with colors
-func Default() *Writer {
-	return NewWriter(os.Stdout, os.Stderr, true)
-}
-
-// colorize wraps text in color codes if colors are enabled
-func (w *Writer) colorize(color, text string) string {
-	if !w.colors {
-		return text
-	}
-	return color + text + Reset
-}
-
-// Info prints an informational message
-func (w *Writer) Info(format string, args ...interface{}) {
-	msg := fmt.Sprintf(format, args...)
-	fmt.Fprintln(w.out, w.colorize(Blue, msg))
-}
-
-// Success prints a success message
-func (w *Writer) Success(format string, args ...interface{}) {
-	msg := fmt.Sprintf(format, args...)
-	fmt.Fprintln(w.out, w.colorize(Green, msg))
-}
-
-// Warning prints a warning message
-func (w *Writer) Warning(format string, args ...interface{}) {
-	msg := fmt.Sprintf(format, args...)
-	fmt.Fprintln(w.err, w.colorize(Yellow, msg))
-}
-
-// Error prints an error message
-func (w *Writer) Error(format string, args ...interface{}) {
-	msg := fmt.Sprintf(format, args...)
-	fmt.Fprintln(w.err, w.colorize(Red, msg))
-}
-
-// Print prints a message without color
-func (w *Writer) Print(format string, args ...interface{}) {
-	fmt.Fprintf(w.out, format, args...)
-}
-
-// Println prints a message with newline without color
-func (w *Writer) Println(format string, args ...interface{}) {
-	msg := fmt.Sprintf(format, args...)
-	fmt.Fprintln(w.out, msg)
-}
tools/gh-pr/internal/templates/templates.go
@@ -1,216 +0,0 @@
-package templates
-
-import (
-	"crypto/sha256"
-	"fmt"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/vdemeester/home/tools/gh-pr/internal/cache"
-)
-
-// Template represents a PR template file
-type Template struct {
-	Path    string
-	Name    string
-	Content string
-}
-
-// Finder finds and caches PR templates
-type Finder struct {
-	cache *cache.Cache
-}
-
-// NewFinder creates a new template finder
-func NewFinder() (*Finder, error) {
-	c, err := cache.New(cache.DefaultTTL)
-	if err != nil {
-		return nil, fmt.Errorf("failed to create cache: %w", err)
-	}
-
-	return &Finder{
-		cache: c,
-	}, nil
-}
-
-// Find locates all PR templates in the current repository
-// If refresh is true, bypasses cache and performs fresh search
-func (f *Finder) Find(refresh bool) ([]Template, error) {
-	// Generate cache key based on current directory
-	cwd, err := os.Getwd()
-	if err != nil {
-		return nil, err
-	}
-
-	cacheKey := f.generateCacheKey(cwd)
-
-	// Try cache first unless refresh is requested
-	if !refresh {
-		var cached []Template
-		if err := f.cache.Get(cacheKey, &cached); err == nil && cached != nil {
-			return cached, nil
-		}
-	}
-
-	// Search for templates
-	templates, err := f.searchTemplates(".")
-	if err != nil {
-		return nil, err
-	}
-
-	// Cache the results
-	if err := f.cache.Set(cacheKey, templates); err != nil {
-		// Don't fail if caching fails, just log and continue
-		fmt.Fprintf(os.Stderr, "Warning: failed to cache templates: %v\n", err)
-	}
-
-	return templates, nil
-}
-
-// FindInRepo locates all PR templates in a remote repository
-// The repo parameter should be in "owner/repo" format
-// If refresh is true, bypasses cache and performs fresh search
-func (f *Finder) FindInRepo(repo string, refresh bool) ([]Template, error) {
-	cacheKey := f.generateCacheKey(fmt.Sprintf("remote:%s", repo))
-
-	// Try cache first unless refresh is requested
-	if !refresh {
-		var cached []Template
-		if err := f.cache.Get(cacheKey, &cached); err == nil && cached != nil {
-			return cached, nil
-		}
-	}
-
-	// Create temporary directory for cloning
-	tmpDir, err := os.MkdirTemp("", "gh-pr-templates-*")
-	if err != nil {
-		return nil, fmt.Errorf("failed to create temp directory: %w", err)
-	}
-	defer os.RemoveAll(tmpDir)
-
-	// Clone repository using gh (shallow clone for speed)
-	cloneDir := filepath.Join(tmpDir, "repo")
-	cmd := exec.Command("gh", "repo", "clone", repo, cloneDir, "--", "--depth", "1")
-	if output, err := cmd.CombinedOutput(); err != nil {
-		return nil, fmt.Errorf("failed to clone repository %s: %w\nOutput: %s", repo, err, string(output))
-	}
-
-	// Search for templates in the cloned repository
-	templates, err := f.searchTemplates(cloneDir)
-	if err != nil {
-		return nil, err
-	}
-
-	// Update template paths to indicate they're from a remote repo
-	for i := range templates {
-		templates[i].Path = fmt.Sprintf("%s:%s", repo, templates[i].Path)
-	}
-
-	// Cache the results
-	if err := f.cache.Set(cacheKey, templates); err != nil {
-		// Don't fail if caching fails, just log and continue
-		fmt.Fprintf(os.Stderr, "Warning: failed to cache templates: %v\n", err)
-	}
-
-	return templates, nil
-}
-
-// ClearCache removes cached template information
-func (f *Finder) ClearCache() error {
-	return f.cache.Clear()
-}
-
-// generateCacheKey creates a unique cache key for the current repository
-func (f *Finder) generateCacheKey(dir string) string {
-	h := sha256.New()
-	h.Write([]byte(dir))
-	h.Write([]byte(time.Now().Format("2006-01-02"))) // Include date for daily refresh
-	return fmt.Sprintf("templates-%x", h.Sum(nil))
-}
-
-// searchTemplates performs the actual search for PR templates in a given base directory
-func (f *Finder) searchTemplates(baseDir string) ([]Template, error) {
-	var templates []Template
-
-	// Common locations for PR templates
-	locations := []string{
-		".github/PULL_REQUEST_TEMPLATE.md",
-		".github/pull_request_template.md",
-		".github/PULL_REQUEST_TEMPLATE/",
-		"docs/PULL_REQUEST_TEMPLATE.md",
-		"docs/pull_request_template.md",
-	}
-
-	for _, loc := range locations {
-		fullPath := filepath.Join(baseDir, loc)
-		info, err := os.Stat(fullPath)
-		if err != nil {
-			continue
-		}
-
-		if info.IsDir() {
-			// List all markdown files in the directory
-			entries, err := os.ReadDir(fullPath)
-			if err != nil {
-				continue
-			}
-
-			for _, entry := range entries {
-				if entry.IsDir() {
-					continue
-				}
-
-				name := entry.Name()
-				if !strings.HasSuffix(name, ".md") {
-					continue
-				}
-
-				path := filepath.Join(fullPath, name)
-				content, err := os.ReadFile(path)
-				if err != nil {
-					continue
-				}
-
-				// Use relative path for display
-				relPath := filepath.Join(loc, name)
-				templates = append(templates, Template{
-					Path:    relPath,
-					Name:    strings.TrimSuffix(name, ".md"),
-					Content: string(content),
-				})
-			}
-		} else {
-			content, err := os.ReadFile(fullPath)
-			if err != nil {
-				continue
-			}
-
-			// Extract name from path
-			name := filepath.Base(loc)
-			name = strings.TrimSuffix(name, ".md")
-			if name == "PULL_REQUEST_TEMPLATE" || name == "pull_request_template" {
-				name = "default"
-			}
-
-			templates = append(templates, Template{
-				Path:    loc,
-				Name:    name,
-				Content: string(content),
-			})
-		}
-	}
-
-	return templates, nil
-}
-
-// ReadTemplate reads a specific template file
-func ReadTemplate(path string) (string, error) {
-	content, err := os.ReadFile(path)
-	if err != nil {
-		return "", fmt.Errorf("failed to read template %s: %w", path, err)
-	}
-	return string(content), nil
-}
tools/gh-pr/go.mod
@@ -1,10 +0,0 @@
-module github.com/vdemeester/home/tools/gh-pr
-
-go 1.23
-
-require github.com/spf13/cobra v1.8.1
-
-require (
-	github.com/inconshreveable/mousetrap v1.1.0 // indirect
-	github.com/spf13/pflag v1.0.5 // indirect
-)
tools/gh-pr/go.sum
@@ -1,10 +0,0 @@
-github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
-github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
-github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
tools/gh-pr/README.md
@@ -1,591 +0,0 @@
-# gh-pr
-
-A comprehensive GitHub Pull Request management tool written in Go, consolidating PR creation with template support, workflow management, and conflict resolution.
-
-## Features
-
-- **PR Creation with Templates**: Create pull requests with automatic template discovery and caching
-- **Template Management**: List and preview templates from local or remote repositories
-- **Remote Template Discovery**: Browse templates from any GitHub repository
-- **Batch Commenting**: Comment on multiple pull requests at once using fzf multi-select
-- **Workflow Restart**: Automatically restart failed GitHub Actions workflows
-- **Conflict Resolution**: Interactive merge conflict resolution with worktree support
-- **Template Caching**: Templates are cached for one week to speed up operations
-
-## Installation
-
-```bash
-# Build with Nix
-nix build .#gh-pr
-
-# Or install to your profile
-nix profile install .#gh-pr
-```
-
-## Commands
-
-### `gh-pr create`
-
-Create a pull request with optional template support.
-
-```bash
-# Create PR interactively (uses gh defaults)
-gh-pr create
-
-# Create PR with a specific template
-gh-pr create --template bug-fix
-
-# Create draft PR with title and body
-gh-pr create --title "Fix bug" --body "Description" --draft
-
-# Refresh template cache
-gh-pr create --refresh
-
-# Full example with all options
-gh-pr create \
-  --title "Add new feature" \
-  --template feature \
-  --draft \
-  --reviewer user1,user2 \
-  --assignee user3 \
-  --label enhancement,feature \
-  --base main \
-  --head feature-branch
-```
-
-**Options:**
-- `-t, --title`: Pull request title
-- `-b, --body`: Pull request body (overridden by template if both are provided)
-- `--template`: Template name or path
-- `-d, --draft`: Create as draft PR
-- `--base`: Base branch (default: repository default)
-- `--head`: Head branch (default: current branch)
-- `-w, --web`: Open in web browser
-- `-r, --reviewer`: Request reviewers (comma-separated)
-- `-a, --assignee`: Assign users (comma-separated)
-- `-l, --label`: Add labels (comma-separated)
-- `--refresh`: Bypass template cache and search again
-
-**Template Discovery:**
-
-Templates are automatically discovered from:
-- `.github/PULL_REQUEST_TEMPLATE.md`
-- `.github/PULL_REQUEST_TEMPLATE/`
-- `docs/PULL_REQUEST_TEMPLATE.md`
-
-### `gh-pr comment`
-
-Comment on multiple pull requests at once using interactive selection.
-
-```bash
-# Interactive mode - select PRs and enter comment
-gh-pr comment
-
-# Pre-specify the comment body
-gh-pr comment --body "LGTM! Approving this change."
-
-# Filter by label before selecting
-gh-pr comment --label bug --label urgent
-
-# Work with a specific repository
-gh-pr comment --repo owner/repo
-
-# Include closed PRs in the selection
-gh-pr comment --state all
-
-# Filter by author
-gh-pr comment --author username
-```
-
-**Options:**
-- `-b, --body`: Comment body (will prompt if not provided)
-- `-R, --repo`: Repository in "owner/repo" format
-- `-l, --label`: Filter PRs by label (can be used multiple times)
-- `-a, --author`: Filter PRs by author
-- `-s, --state`: Filter by state: open, closed, merged, all (default: open)
-
-**How It Works:**
-
-1. **List PRs**: Fetches pull requests matching your filters using `gh pr list`
-2. **Select**: Uses fzf for multi-select with preview pane showing:
-   - PR title and author
-   - CI check status with visual indicators (✓/✗/●/○)
-   - Tab to select, Enter to confirm
-3. **Comment**: Prompts for comment body if not provided via `--body`
-4. **Post**: Posts the same comment to all selected PRs
-
-**Use Cases:**
-- Notify multiple PRs about a related change
-- Request updates across multiple related PRs
-- Add acknowledgments to a batch of PRs
-- Communicate breaking changes to affected PRs
-
-### `gh-pr approve`
-
-Approve and optionally merge pull requests interactively.
-
-```bash
-# Approve PRs from default projects (Tekton)
-gh-pr approve
-
-# Approve PRs from specific projects
-gh-pr approve tektoncd/pipeline tektoncd/cli
-
-# Approve with Prow comment (/lgtm)
-gh-pr approve -p tektoncd/pipeline
-
-# Approve and merge
-gh-pr approve -m tektoncd/pipeline
-
-# Approve with custom comment
-gh-pr approve -c "LGTM! Great work" tektoncd/pipeline
-
-# Approve with editor for multi-line comment
-gh-pr approve -C tektoncd/pipeline
-
-# Force merge (requires admin rights)
-gh-pr approve -m -f tektoncd/pipeline
-
-# Interactive comment prompt
-gh-pr approve -i tektoncd/pipeline
-```
-
-**Options:**
-- `-p, --prow`: Add Prow `/lgtm` comment (for Prow-based repos)
-- `-m, --merge`: Merge PR after approval (uses `--rebase` and `--delete-branch`)
-- `-f, --force`: Force merge with `--admin` flag (requires admin rights)
-- `-c, --comment`: Custom approval comment
-- `-C, --editor`: Open editor for multi-line comment
-- `-i, --interactive`: Prompt for comment interactively
-
-**Positional Arguments:**
-- `projects`: GitHub repositories in `owner/repo` format (optional)
-  - If not provided, uses default Tekton projects:
-    - `tektoncd/pipeline`
-    - `tektoncd/plumbing`
-    - `tektoncd/cli`
-    - `tektoncd/mcp-server`
-
-**How It Works:**
-
-1. **List PRs**: Fetches pull requests from each specified project
-2. **Select**: Uses fzf for multi-select with preview pane showing:
-   - PR number, author, and title
-   - CI check status (Tab to select, Enter to confirm)
-3. **Approve**: Approves all selected PRs with optional comment
-4. **Merge** (optional): If `-m` is set, merges approved PRs
-   - Uses `--rebase` to maintain linear history
-   - Uses `--delete-branch` to clean up after merge
-   - Uses `--admin` if `-f` is set (force merge)
-
-**Comment Handling:**
-
-The tool provides three ways to add approval comments:
-
-1. **Inline comment** (`-c`): Quick one-line comment
-   ```bash
-   gh-pr approve -c "LGTM! Nice work" tektoncd/pipeline
-   ```
-
-2. **Editor comment** (`-C`): Multi-line comment using your `$EDITOR`
-   ```bash
-   gh-pr approve -C tektoncd/pipeline
-   ```
-
-3. **Interactive prompt** (`-i`): Prompt for comment at runtime
-   ```bash
-   gh-pr approve -i tektoncd/pipeline
-   ```
-
-**Prow Integration:**
-
-For repositories using Prow (like Tekton projects), use the `-p` flag to automatically add the `/lgtm` command:
-
-```bash
-# This will approve with comment: "/lgtm\n\nLooks good!"
-gh-pr approve -p -c "Looks good!" tektoncd/pipeline
-```
-
-**Use Cases:**
-- Batch approve multiple related PRs
-- Approve and merge PRs in one workflow
-- Add consistent comments across multiple PRs
-- Work with multiple projects efficiently
-
-### `gh-pr list-templates`
-
-List all available PR templates in the current or a remote repository.
-
-```bash
-# List templates in current repository
-gh-pr list-templates
-
-# List templates from a remote repository
-gh-pr list-templates tektoncd/pipeline
-
-# Show template content preview
-gh-pr list-templates --verbose
-
-# Refresh cache and list templates
-gh-pr list-templates --refresh
-
-# Browse templates from any repo
-gh-pr list-templates kubernetes/kubernetes --verbose
-```
-
-**Options:**
-- `[REPOSITORY]`: Optional repository in "owner/repo" format to search
-- `--refresh`: Refresh template cache
-- `-v, --verbose`: Show template content preview
-
-**Remote Repository Support:**
-
-You can now browse templates from any GitHub repository without cloning it first! The tool will:
-1. Shallow clone the repository to a temporary directory
-2. Search for PR templates
-3. Cache the results for one week
-4. Clean up the temporary clone
-
-This is especially useful for:
-- Exploring templates from organizations you contribute to
-- Finding good template examples from popular projects
-- Quickly checking if a repository uses PR templates
-
-### `gh-pr restart-failed`
-
-Restart failed workflow runs on pull requests with interactive selection.
-
-```bash
-# Interactive mode - select PRs with failed checks using fzf
-gh-pr restart-failed
-
-# Restart workflows for a specific PR (no selection needed)
-gh-pr restart-failed owner/repo#123
-
-# Filter by label before selecting
-gh-pr restart-failed --label bug
-
-# Ignore specific workflows
-gh-pr restart-failed --ignore "build" --ignore "test"
-
-# Work with a specific repository
-gh-pr restart-failed owner/repo
-```
-
-**Options:**
-- `-i, --ignore`: Ignore workflows matching pattern (can be used multiple times)
-- `-l, --label`: Filter PRs by label (can be used multiple times)
-
-**How It Works:**
-
-1. **Find Failed PRs**: Fetches all PRs and filters those with failed checks
-2. **Select**: Uses fzf for multi-select with preview pane showing:
-   - All CI check statuses with visual indicators (✓/✗/●/○)
-   - Author information
-   - Tab to select, Enter to confirm
-3. **Restart**: Restarts failed workflows for all selected PRs
-
-**Default Behavior:**
-- "Label Checker" workflows are ignored by default
-- Only restarts workflows that failed due to:
-  - `failure`
-  - `timed_out`
-  - `startup_failure`
-  - `action_required`
-- Shows failed count for each PR in the selection interface
-
-### `gh-pr resolve-conflicts`
-
-Resolve merge conflicts in pull requests interactively with full worktree support and fzf selection.
-
-```bash
-# Search for your conflicting PRs and select with fzf
-gh-pr resolve-conflicts
-
-# Resolve a specific PR (no selection)
-gh-pr resolve-conflicts owner/repo#123
-
-# Filter by organization before selecting
-gh-pr resolve-conflicts -o tektoncd
-
-# Use existing repo instead of creating worktree
-gh-pr resolve-conflicts -n
-
-# Don't auto-push after resolving
-gh-pr resolve-conflicts -N
-
-# Specify custom worktree directory
-gh-pr resolve-conflicts -w /tmp/my-worktrees
-```
-
-**Options:**
-- `-w, --worktree DIR`: Create worktrees in specified directory (default: `/tmp/gh-resolve-conflicts-worktrees`)
-- `-n, --no-worktree`: Use existing repo instead of creating worktrees
-- `-N, --no-push`: Don't automatically force-push after resolution
-- `-o, --org ORG`: Filter PRs by organization
-- `-a, --author USER`: Filter PRs by author (default: `@me`)
-
-**How It Works:**
-
-1. **Find Conflicting PRs**: Searches for open PRs with merge conflicts
-2. **Select**: Uses fzf for multi-select with preview pane showing:
-   - Branch information (head → base)
-   - Merge status
-   - CI check status with visual indicators (✓/✗/●/○)
-   - Tab to select, Enter to confirm
-3. **Setup Worktree**: Creates an isolated worktree for each PR (or uses existing repo)
-4. **Fetch Branches**: Fetches both the PR branch and upstream base branch
-5. **Rebase**: Attempts to rebase the PR onto the base branch
-6. **Resolve Conflicts**: Launches conflict resolution tool:
-   - Tries `emacs` with ediff mode first
-   - Falls back to `git mergetool` if emacs is unavailable
-7. **Force Push**: Optionally force-pushes the resolved changes
-
-**Fork Support:**
-
-The tool automatically handles forked repositories:
-- Detects cross-repository PRs
-- Adds upstream remote when needed
-- Fetches from both fork and upstream
-- Pushes to the correct fork after resolution
-
-**Worktree Benefits:**
-
-Using worktrees (default behavior) allows you to:
-- Resolve conflicts in isolated environments
-- Work on multiple PRs simultaneously
-- Keep your main repository clean
-- Easily discard worktrees after resolution
-
-### `gh-pr cleanup`
-
-Clean up git worktrees that were created during conflict resolution or other operations.
-
-```bash
-# Dry run - see what would be removed
-gh-pr cleanup --dry-run
-
-# Clean up worktrees in default directory
-gh-pr cleanup
-
-# Clean up worktrees in custom directory
-gh-pr cleanup ~/my-worktrees
-
-# Only remove worktrees where commits are merged upstream
-gh-pr cleanup --check-upstream
-
-# Only remove worktrees for merged/closed PRs
-gh-pr cleanup --check-merged
-
-# Check both upstream merge status and PR status
-gh-pr cleanup --check-upstream --check-merged
-
-# Use custom upstream branch for merge check
-gh-pr cleanup --check-upstream --upstream-branch master
-
-# Force remove even with uncommitted changes (dangerous!)
-gh-pr cleanup --force
-```
-
-**Options:**
-- `-w, --worktree`: Directory containing worktrees (default: `/tmp/gh-resolve-conflicts-worktrees`)
-- `-n, --dry-run`: Show what would be removed without actually removing
-- `-u, --check-upstream`: Check if commits from the branch are merged upstream
-- `-b, --upstream-branch`: Upstream branch to check against (default: `main`)
-- `-m, --check-merged`: Only remove worktrees for merged or closed PRs
-- `-f, --force`: Force remove even if there are uncommitted changes (use with caution!)
-- `[DIRECTORY]`: Positional argument to specify the worktree base directory
-
-**How It Works:**
-
-1. **Scan**: Searches for git repositories with worktrees in the specified directory
-2. **Analyze**: For each worktree:
-   - Checks for uncommitted changes
-   - Optionally checks if commits are merged upstream (with `--check-upstream`)
-   - Optionally checks if the associated PR is merged/closed (with `--check-merged`)
-3. **Clean**: Removes worktrees based on merge status:
-   - **Without checks**: Removes all clean worktrees
-   - **With checks**: Only removes worktrees where commits are merged upstream or PR is merged
-4. **Report**: Shows summary with color-coded status:
-   - ✅ Green checkmark: Merged upstream or PR merged (safe to remove)
-   - 🗑️ Trash can: Clean worktree (standard removal)
-   - ⚠️ Warning: Has uncommitted changes (keeping)
-   - ℹ️ Info: Not merged or still open (keeping)
-
-**Merge Detection:**
-
-The `--check-upstream` flag uses `git cherry` to detect if all commits from a branch have been merged upstream:
-- Compares commits between the worktree branch and the upstream branch (default: `main`)
-- Considers a branch merged if all its commits exist in upstream
-- Works even if the PR was squash-merged or rebased
-- Different from PR status - detects actual commit presence in upstream
-
-**Use Cases:**
-- Clean up after resolving multiple PR conflicts
-- Remove stale worktrees from failed resolution attempts
-- Reclaim disk space from old PR worktrees
-- Batch cleanup of merged PR worktrees
-- Safely remove worktrees only when changes are actually merged
-- Clean up squash-merged PRs that may still show as "open"
-
-**Safety:**
-- By default, keeps worktrees with uncommitted changes
-- Use `--dry-run` first to preview what will be removed
-- Use `--check-upstream` to only remove when commits are safely merged
-- Use `--check-merged` to only remove worktrees for PRs that are already merged/closed
-- Combine both checks for maximum safety
-
-## Template Caching
-
-Templates are cached for **7 days** (one week) by default. This significantly speeds up operations when working with the same repository.
-
-**Cache Location:** `~/.cache/gh-pr/`
-
-**Cache Invalidation:**
-- Use `--refresh` flag on any command that uses templates
-- Cache automatically expires after 7 days
-- Manual deletion: `rm -rf ~/.cache/gh-pr/`
-
-## Architecture
-
-The tool is organized into several packages:
-
-```
-tools/gh-pr/
-├── cmd/gh-pr/              # Main command and subcommands
-│   ├── main.go            # Entry point and root command
-│   ├── create.go          # PR creation
-│   ├── list_templates.go  # Template listing
-│   ├── restart_failed.go  # Workflow restart
-│   ├── resolve_conflicts.go # Conflict resolution
-│   ├── cleanup.go         # Worktree cleanup
-│   ├── comment.go         # Batch commenting
-│   └── approve.go         # Batch approval
-├── internal/
-│   ├── cache/             # Caching with TTL support
-│   ├── output/            # Colored terminal output
-│   └── templates/         # Template discovery and management
-├── go.mod
-├── default.nix            # Nix package definition
-└── README.md
-```
-
-## Examples
-
-### Creating a PR from Claude Code
-
-When Claude suggests creating a PR, the workflow is streamlined:
-
-```bash
-# List available templates
-gh-pr list-templates
-
-# Create PR with a template
-gh-pr create --template feature --title "Add user authentication"
-
-# Create PR with custom content
-gh-pr create \
-  --title "Implement OAuth login" \
-  --body "Adds OAuth 2.0 support for Google and GitHub" \
-  --draft \
-  --reviewer team-lead
-```
-
-### Commenting on Multiple PRs
-
-```bash
-# Select and comment on multiple PRs interactively
-gh-pr comment
-
-# Comment on all PRs with a specific label
-gh-pr comment --label needs-rebase --body "Please rebase on main"
-
-# Notify all your open PRs about a breaking change
-gh-pr comment --author @me --body "Note: This depends on #1234"
-```
-
-### Restarting Failed Workflows
-
-```bash
-# Select PRs with failures and restart workflows
-gh-pr restart-failed
-
-# Restart specific PR in another repo (no selection)
-gh-pr restart-failed tektoncd/pipeline#1234
-
-# Filter and select PRs with specific label
-gh-pr restart-failed --label bug
-
-# Ignore flaky tests
-gh-pr restart-failed --ignore "e2e-tests"
-```
-
-### Cleaning Up Worktrees
-
-```bash
-# Preview what would be cleaned (dry run)
-gh-pr cleanup --dry-run
-
-# Clean up all clean worktrees
-gh-pr cleanup
-
-# Only remove worktrees where commits are merged upstream
-gh-pr cleanup --check-upstream
-
-# Only remove worktrees for merged PRs
-gh-pr cleanup --check-merged
-
-# Maximum safety: check both upstream and PR status
-gh-pr cleanup --check-upstream --check-merged --dry-run
-
-# Clean up a specific directory
-gh-pr cleanup ~/tekton-worktrees
-```
-
-## Integration with Existing Tools
-
-This tool consolidates and replaces:
-
-- `gh-approve`: Now integrated as `gh-pr approve`
-- `gh-restart-failed`: Now integrated as `gh-pr restart-failed`
-- `gh-resolve-conflicts`: Now integrated as `gh-pr resolve-conflicts`
-
-The old shell scripts are now deprecated in favor of this unified Go tool.
-
-## Development
-
-```bash
-# Run tests
-go test ./...
-
-# Format code
-go fmt ./...
-
-# Build locally
-go build -o gh-pr ./cmd/gh-pr
-
-# Build with Nix
-nix build .#gh-pr
-```
-
-## Dependencies
-
-- `gh` (GitHub CLI) - Required for all GitHub operations
-- `fzf` - Required for interactive PR selection with preview
-- `jq` - Required for formatting preview pane and JSON parsing
-- Go 1.23+ - For building from source
-
-## License
-
-MIT
-
-## Future Enhancements
-
-- [x] Full implementation of `resolve-conflicts` command
-- [x] Remote repository template discovery
-- [x] Batch operations on multiple PRs (comment command)
-- [ ] Interactive PR selection with `fzf` integration for conflict resolution
-- [ ] Support for PR templates in multiple formats (YAML, JSON)
-- [ ] Custom cache TTL configuration
-- [ ] Integration with review tools
-- [ ] Template validation and linting