claude.go

166 lines
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166
package commands

import (
	"fmt"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"syscall"

	"congo.gg"
)

const claudeUsage = `Launch Claude Code with Congo framework context

Usage:
  congo claude [flags]

Generates a CLAUDE.md with framework conventions and detected
project state, then opens Claude Code in the current directory.

Any additional flags are passed through to Claude Code.
`

func Claude() {
	// Handle --help before exec replacement.
	for _, arg := range os.Args[2:] {
		if arg == "--help" || arg == "-h" {
			fmt.Print(claudeUsage)
			return
		}
	}

	claudePath, err := exec.LookPath("claude")
	if err != nil {
		log.Fatal("Claude Code not found. Install from https://claude.ai/code")
	}

	ensureClaudeContext()

	// Replace process with claude, passing through all args.
	args := append([]string{"claude"}, os.Args[2:]...)
	if err := syscall.Exec(claudePath, args, os.Environ()); err != nil {
		log.Fatalf("exec claude: %v", err)
	}
}

// ensureClaudeContext generates a CLAUDE.md at the project root
// with framework conventions and detected project state.
// Skips if CLAUDE.md already exists to avoid overwriting user customizations.
func ensureClaudeContext() {
	if _, err := os.Stat("CLAUDE.md"); err == nil {
		return
	}

	var b strings.Builder

	// Start with embedded framework documentation.
	b.WriteString(congo.ClaudeContext)

	// Detect vendored packages.
	b.WriteString("\n\n## Vendored Packages\n\n")
	for _, pkg := range []string{"application", "database", "frontend", "assistant", "platform"} {
		if _, err := os.Stat("internal/" + pkg); err == nil {
			b.WriteString(fmt.Sprintf("- `internal/%s/` available\n", pkg))
		}
	}

	// Read module name from go.mod.
	if data, err := os.ReadFile("go.mod"); err == nil {
		for _, line := range strings.Split(string(data), "\n") {
			if strings.HasPrefix(line, "module ") {
				b.WriteString(fmt.Sprintf("\n## Module\n\n`%s`\n", strings.TrimPrefix(line, "module ")))
				break
			}
		}
	}

	// Detect project structure.
	b.WriteString("\n## Project Structure\n\n")
	appDir := findAppDirSilent()
	if appDir != "" {
		b.WriteString(fmt.Sprintf("App directory: `%s/`\n\n", appDir))

		// Scan controllers.
		if controllers := scanGoFiles(filepath.Join(appDir, "controllers")); len(controllers) > 0 {
			b.WriteString("Controllers:\n")
			for _, c := range controllers {
				b.WriteString(fmt.Sprintf("- `%s`\n", c))
			}
			b.WriteString("\n")
		}

		// Scan models.
		if models := scanGoFiles(filepath.Join(appDir, "models")); len(models) > 0 {
			b.WriteString("Models:\n")
			for _, m := range models {
				b.WriteString(fmt.Sprintf("- `%s`\n", m))
			}
			b.WriteString("\n")
		}

		// Detect React components.
		if components := scanFiles(filepath.Join(appDir, "components"), ".tsx", ".jsx"); len(components) > 0 {
			b.WriteString("React Islands:\n")
			for _, c := range components {
				b.WriteString(fmt.Sprintf("- `%s`\n", c))
			}
			b.WriteString("\n")
		}
	}

	// Detect deployment config.
	if _, err := os.Stat("infra.json"); err == nil {
		b.WriteString("## Deployment\n\n")
		b.WriteString("Configured in `infra.json`. Use `congo launch` to deploy.\n")
	}

	if err := os.WriteFile("CLAUDE.md", []byte(b.String()), 0644); err != nil {
		log.Fatalf("write CLAUDE.md: %v", err)
	}
}

// findAppDirSilent locates the app directory without fataling.
func findAppDirSilent() string {
	for _, dir := range []string{"web", "app", "api", "cmd"} {
		if _, err := os.Stat(filepath.Join(dir, "main.go")); err == nil {
			return dir
		}
	}
	if _, err := os.Stat("main.go"); err == nil {
		return "."
	}
	return ""
}

// scanGoFiles returns basenames of .go files in a directory (excluding test files).
func scanGoFiles(dir string) []string {
	return scanFiles(dir, ".go")
}

// scanFiles returns basenames of files matching any of the given extensions.
func scanFiles(dir string, exts ...string) []string {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil
	}
	var names []string
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		if strings.HasSuffix(name, "_test.go") {
			continue
		}
		for _, ext := range exts {
			if strings.HasSuffix(name, ext) {
				names = append(names, name)
				break
			}
		}
	}
	return names
}