diff --git a/detect.go b/detect.go new file mode 100644 index 0000000..47c659a --- /dev/null +++ b/detect.go @@ -0,0 +1,435 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" +) + +// ProjectInfo holds detected project metadata for template placeholder replacement. +type ProjectInfo struct { + Name string + Description string + Framework string + LanguageTemplate string + TestCommand string + TypecheckCommand string + BuildCommand string + KeyDirectories []string +} + +// packageJSON is a minimal struct for parsing package.json fields. +type packageJSON struct { + Name string `json:"name"` + Description string `json:"description"` + Scripts map[string]string `json:"scripts"` + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` +} + +func mergeMaps(a, b map[string]string) map[string]string { + result := make(map[string]string) + for k, v := range a { + result[k] = v + } + for k, v := range b { + result[k] = v + } + return result +} + +func hasKeyPrefix(m map[string]string, prefix string) bool { + for k := range m { + if strings.HasPrefix(k, prefix) { + return true + } + } + return false +} + +// extractTOMLString extracts a string value from a simple TOML `key = "value"` or `key = 'value'` line. +// Handles both double-quoted and single-quoted (literal) TOML strings on a single line. +// Sufficient for name/description fields. +func extractTOMLString(line string) string { + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return "" + } + val := strings.TrimSpace(parts[1]) + if strings.HasPrefix(val, "\"") { + val = strings.Trim(val, "\"") + } else if strings.HasPrefix(val, "'") { + val = strings.Trim(val, "'") + } + return val +} + +func formatKeyDirectories(dirs []string) string { + if len(dirs) == 0 { + return "" + } + var lines []string + for _, d := range dirs { + lines = append(lines, fmt.Sprintf("- `%s`", d)) + } + return strings.Join(lines, "\n") +} + +// Package-level compiled regexps used in applyProjectDetection. +var ( + placeholderRe = regexp.MustCompile(`\{\{[A-Z_]+\}\}`) + emptyBacktickArtifact = regexp.MustCompile("`\\s*\\+\\s*`\\s*`") + codeFenceLeadingBlank = regexp.MustCompile("(?m)^(```bash\n)\\s*\n") + codeFenceTrailingBlank = regexp.MustCompile("(?m)\n\\s*\n(```)") +) + +func detectKeyDirectories(dir string) []string { + candidates := []string{ + "src", "lib", "app", "cmd", "internal", "pkg", + "api", "server", "client", "web", "frontend", "backend", + "tests", "test", "spec", "e2e", + "docs", "config", "scripts", "migrations", "public", + "resources", "inertia", + } + var found []string + for _, name := range candidates { + fi, err := os.Stat(filepath.Join(dir, name)) + if err == nil && fi.IsDir() { + found = append(found, name+"/") + } + } + return found +} + +func detectGoProject(dir string) ProjectInfo { + info := ProjectInfo{ + Framework: "Go", + LanguageTemplate: "go.md", + TestCommand: "go test ./...", + BuildCommand: "go build ./...", + } + data, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { + return info + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + modulePath := strings.TrimSpace(strings.TrimPrefix(line, "module ")) + parts := strings.Split(modulePath, "/") + info.Name = parts[len(parts)-1] + break + } + } + return info +} + +func detectRustProject(dir string) ProjectInfo { + info := ProjectInfo{ + Framework: "Rust", + LanguageTemplate: "rust.md", + TestCommand: "cargo test", + BuildCommand: "cargo build", + } + data, err := os.ReadFile(filepath.Join(dir, "Cargo.toml")) + if err != nil { + return info + } + inPackage := false + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "[package]" { + inPackage = true + continue + } + if strings.HasPrefix(trimmed, "[") { + inPackage = false + continue + } + if !inPackage { + continue + } + if strings.HasPrefix(trimmed, "name") { + info.Name = extractTOMLString(trimmed) + } else if strings.HasPrefix(trimmed, "description") { + info.Description = extractTOMLString(trimmed) + } + } + return info +} + +// detectPythonProject detects Python projects from pyproject.toml or requirements.txt. +// For requirements.txt-only projects (no pyproject.toml), the defaults are returned as-is +// (Framework="Python", TestCommand="pytest") since requirements.txt has no name/description +// fields to extract. +func detectPythonProject(dir string) ProjectInfo { + info := ProjectInfo{ + Framework: "Python", + LanguageTemplate: "python.md", + TestCommand: "pytest", + TypecheckCommand: "mypy .", + } + data, err := os.ReadFile(filepath.Join(dir, "pyproject.toml")) + if err != nil { + return info + } + inProject := false + inPoetry := false + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "[project]" { + inProject = true + inPoetry = false + continue + } + if trimmed == "[tool.poetry]" { + inPoetry = true + inProject = false + continue + } + if strings.HasPrefix(trimmed, "[") { + inProject = false + inPoetry = false + continue + } + if inProject || inPoetry { + if strings.HasPrefix(trimmed, "name") { + info.Name = extractTOMLString(trimmed) + } else if strings.HasPrefix(trimmed, "description") { + info.Description = extractTOMLString(trimmed) + } + } + } + return info +} + +func detectRubyProject(dir string) ProjectInfo { + info := ProjectInfo{ + Framework: "Ruby", + LanguageTemplate: "ruby.md", + TestCommand: "bundle exec rspec", + BuildCommand: "bundle exec rake build", + } + data, err := os.ReadFile(filepath.Join(dir, "Gemfile")) + if err != nil { + return info + } + content := string(data) + if strings.Contains(content, "'rails'") || strings.Contains(content, "\"rails\"") { + info.Framework = "Rails" + info.BuildCommand = "bundle exec rails assets:precompile" + } + return info +} + +func detectPHPProject(dir string) ProjectInfo { + info := ProjectInfo{ + Framework: "PHP", + LanguageTemplate: "php.md", + TestCommand: "vendor/bin/phpunit", + TypecheckCommand: "vendor/bin/phpstan analyse", + BuildCommand: "composer install", + } + data, err := os.ReadFile(filepath.Join(dir, "composer.json")) + if err != nil { + return info + } + var composer struct { + Name string `json:"name"` + Description string `json:"description"` + Require map[string]string `json:"require"` + } + if err := json.Unmarshal(data, &composer); err != nil { + return info + } + info.Name = composer.Name + info.Description = composer.Description + if _, ok := composer.Require["laravel/framework"]; ok { + info.Framework = "Laravel" + info.TestCommand = "php artisan test" + } + return info +} + +func detectNodeProject(dir string) ProjectInfo { + data, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return ProjectInfo{} + } + var pkg packageJSON + if err := json.Unmarshal(data, &pkg); err != nil { + return ProjectInfo{} + } + + info := ProjectInfo{ + Name: pkg.Name, + Description: pkg.Description, + TestCommand: "npm test", + TypecheckCommand: "npx tsc --noEmit", + BuildCommand: "npm run build", + } + + allDeps := mergeMaps(pkg.Dependencies, pkg.DevDependencies) + + // Priority order: most specific first + if hasKeyPrefix(allDeps, "@adonisjs/") { + info.Framework = "AdonisJS" + info.LanguageTemplate = "adonisjs.md" + info.TestCommand = "node ace test" + info.TypecheckCommand = "npx tsc --noEmit" + info.BuildCommand = "node ace build" + } else if _, ok := allDeps["svelte"]; ok { + info.Framework = "Svelte" + info.LanguageTemplate = "svelte.md" + info.TypecheckCommand = "npx svelte-check" + } else if _, ok := allDeps["next"]; ok { + info.Framework = "Next.js" + info.LanguageTemplate = "react.md" + } else if _, ok := allDeps["react"]; ok { + info.Framework = "React" + info.LanguageTemplate = "react.md" + } else if _, ok := allDeps["express"]; ok { + info.Framework = "Express" + info.LanguageTemplate = "nodejs.md" + } else { + info.Framework = "Node.js" + info.LanguageTemplate = "nodejs.md" + } + + // Script overrides may replace framework-specific commands (e.g., AdonisJS + // "node ace test" becomes "npm test"), which is correct since npm test invokes + // whatever script is defined in package.json. + return overrideFromScripts(info, pkg.Scripts) +} + +func overrideFromScripts(info ProjectInfo, scripts map[string]string) ProjectInfo { + if scripts == nil { + return info + } + if _, ok := scripts["test"]; ok { + info.TestCommand = "npm test" + } else if _, ok := scripts["test:unit"]; ok { + info.TestCommand = "npm run test:unit" + } + if _, ok := scripts["typecheck"]; ok { + info.TypecheckCommand = "npm run typecheck" + } else if _, ok := scripts["type-check"]; ok { + info.TypecheckCommand = "npm run type-check" + } else if _, ok := scripts["check-types"]; ok { + info.TypecheckCommand = "npm run check-types" + } + if _, ok := scripts["build"]; ok { + info.BuildCommand = "npm run build" + } + return info +} + +func detectProject(dir string) ProjectInfo { + info := ProjectInfo{} + pjPath := filepath.Join(dir, "package.json") + goModPath := filepath.Join(dir, "go.mod") + cargoPath := filepath.Join(dir, "Cargo.toml") + reqsPath := filepath.Join(dir, "requirements.txt") + pyprojectPath := filepath.Join(dir, "pyproject.toml") + gemfilePath := filepath.Join(dir, "Gemfile") + composerPath := filepath.Join(dir, "composer.json") + + if fileExists(pjPath) { + info = detectNodeProject(dir) + } else if fileExists(goModPath) { + info = detectGoProject(dir) + } else if fileExists(cargoPath) { + info = detectRustProject(dir) + } else if fileExists(reqsPath) || fileExists(pyprojectPath) { + info = detectPythonProject(dir) + } else if fileExists(gemfilePath) { + info = detectRubyProject(dir) + } else if fileExists(composerPath) { + info = detectPHPProject(dir) + } + + if info.Name == "" { + info.Name = filepath.Base(dir) + } + + info.KeyDirectories = detectKeyDirectories(dir) + return info +} + +func applyProjectDetection(baseContent []byte, info ProjectInfo, embeddedFS fs.FS) []byte { + config := string(baseContent) + + config = strings.ReplaceAll(config, "{{PROJECT_NAME}}", info.Name) + config = strings.ReplaceAll(config, "{{PROJECT_DESCRIPTION}}", info.Description) + config = strings.ReplaceAll(config, "{{KEY_DIRECTORIES}}", formatKeyDirectories(info.KeyDirectories)) + config = strings.ReplaceAll(config, "{{TEST_COMMAND}}", info.TestCommand) + config = strings.ReplaceAll(config, "{{TYPECHECK_COMMAND}}", info.TypecheckCommand) + config = strings.ReplaceAll(config, "{{BUILD_COMMAND}}", info.BuildCommand) + config = strings.ReplaceAll(config, "{{FRAMEWORK}}", info.Framework) + + // Insert language-specific template at marker + if info.LanguageTemplate != "" { + if langContent, err := fs.ReadFile(embeddedFS, "templates/languages/"+info.LanguageTemplate); err == nil { + config = strings.ReplaceAll(config, "", string(langContent)) + } + } + config = strings.ReplaceAll(config, "", "") + + // Strip remaining unfilled placeholders + config = placeholderRe.ReplaceAllString(config, "") + + // Clean up empty command artifacts in tables (e.g., "` + ` `" when typecheck is empty) + config = emptyBacktickArtifact.ReplaceAllString(config, "`") + + // Remove blank lines inside code fences + config = codeFenceLeadingBlank.ReplaceAllString(config, "$1") + config = codeFenceTrailingBlank.ReplaceAllString(config, "\n$1") + + // Remove empty sections and collapse blank lines + config = removeEmptySections(config) + + return []byte(config) +} + +func removeEmptySections(s string) string { + lines := strings.Split(s, "\n") + var result []string + i := 0 + for i < len(lines) { + trimmed := strings.TrimSpace(lines[i]) + if strings.HasPrefix(trimmed, "## ") { + // Look ahead: is the section empty? + j := i + 1 + hasContent := false + for j < len(lines) { + nextTrimmed := strings.TrimSpace(lines[j]) + if strings.HasPrefix(nextTrimmed, "## ") || nextTrimmed == "---" { + break + } + if nextTrimmed != "" && nextTrimmed != "```" && nextTrimmed != "```bash" { + hasContent = true + break + } + j++ + } + if !hasContent { + i = j + continue + } + } + result = append(result, lines[i]) + i++ + } + return collapseBlankLines(strings.Join(result, "\n")) +} + +func collapseBlankLines(s string) string { + for strings.Contains(s, "\n\n\n") { + s = strings.ReplaceAll(s, "\n\n\n", "\n\n") + } + return s +} diff --git a/detect_test.go b/detect_test.go new file mode 100644 index 0000000..49f71dd --- /dev/null +++ b/detect_test.go @@ -0,0 +1,434 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +func writeTestFile(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func TestDetectGoProject(t *testing.T) { + t.Parallel() + dir := t.TempDir() + goMod := `module github.com/user/myapp + +go 1.21 +` + writeTestFile(t, dir, "go.mod", goMod) + + info := detectGoProject(dir) + + if info.Name != "myapp" { + t.Errorf("Name = %q, want %q", info.Name, "myapp") + } + if info.Framework != "Go" { + t.Errorf("Framework = %q, want %q", info.Framework, "Go") + } + if info.TestCommand != "go test ./..." { + t.Errorf("TestCommand = %q, want %q", info.TestCommand, "go test ./...") + } + if info.LanguageTemplate != "go.md" { + t.Errorf("LanguageTemplate = %q, want %q", info.LanguageTemplate, "go.md") + } + if info.BuildCommand != "go build ./..." { + t.Errorf("BuildCommand = %q, want %q", info.BuildCommand, "go build ./...") + } +} + +func TestDetectNodeProject_Frameworks(t *testing.T) { + t.Parallel() + tests := []struct { + name string + packageJSON string + wantFramework string + wantLangTemplate string + }{ + { + name: "React", + packageJSON: `{ + "name": "my-react-app", + "dependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +}`, + wantFramework: "React", + wantLangTemplate: "react.md", + }, + { + name: "NextJS", + packageJSON: `{ + "name": "my-next-app", + "dependencies": { + "next": "^14.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +}`, + wantFramework: "Next.js", + wantLangTemplate: "react.md", + }, + { + name: "AdonisJS", + packageJSON: `{ + "name": "my-adonis-app", + "dependencies": { + "@adonisjs/core": "^6.0.0" + } +}`, + wantFramework: "AdonisJS", + wantLangTemplate: "adonisjs.md", + }, + { + name: "Express", + packageJSON: `{ + "name": "my-express-app", + "dependencies": { + "express": "^4.18.0" + } +}`, + wantFramework: "Express", + wantLangTemplate: "nodejs.md", + }, + { + name: "Node.js fallback", + packageJSON: `{ + "name": "my-plain-app", + "description": "A plain Node.js app" +}`, + wantFramework: "Node.js", + wantLangTemplate: "nodejs.md", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeTestFile(t, dir, "package.json", tt.packageJSON) + + info := detectNodeProject(dir) + + if info.Framework != tt.wantFramework { + t.Errorf("Framework = %q, want %q", info.Framework, tt.wantFramework) + } + if info.LanguageTemplate != tt.wantLangTemplate { + t.Errorf("LanguageTemplate = %q, want %q", info.LanguageTemplate, tt.wantLangTemplate) + } + }) + } +} + +func TestDetectNodeProject_ScriptOverride(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pkg := `{ + "name": "my-app", + "dependencies": { + "react": "^18.0.0" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest", + "build": "vite build" + } +}` + writeTestFile(t, dir, "package.json", pkg) + + info := detectNodeProject(dir) + + if info.TypecheckCommand != "npm run typecheck" { + t.Errorf("TypecheckCommand = %q, want %q", info.TypecheckCommand, "npm run typecheck") + } + if info.TestCommand != "npm test" { + t.Errorf("TestCommand = %q, want %q", info.TestCommand, "npm test") + } + if info.BuildCommand != "npm run build" { + t.Errorf("BuildCommand = %q, want %q", info.BuildCommand, "npm run build") + } +} + +func TestDetectRustProject(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cargo := `[package] +name = "my-rust-tool" +description = "A blazingly fast CLI tool" +version = "0.1.0" +edition = "2021" + +[dependencies] +` + writeTestFile(t, dir, "Cargo.toml", cargo) + + info := detectRustProject(dir) + + if info.Name != "my-rust-tool" { + t.Errorf("Name = %q, want %q", info.Name, "my-rust-tool") + } + if info.Description != "A blazingly fast CLI tool" { + t.Errorf("Description = %q, want %q", info.Description, "A blazingly fast CLI tool") + } + if info.Framework != "Rust" { + t.Errorf("Framework = %q, want %q", info.Framework, "Rust") + } + if info.TestCommand != "cargo test" { + t.Errorf("TestCommand = %q, want %q", info.TestCommand, "cargo test") + } +} + +func TestDetectPythonProject(t *testing.T) { + t.Parallel() + dir := t.TempDir() + pyproject := `[project] +name = "my-python-lib" +version = "1.0.0" +description = "A Python library" + +[build-system] +requires = ["setuptools"] +` + writeTestFile(t, dir, "pyproject.toml", pyproject) + + info := detectPythonProject(dir) + + if info.Name != "my-python-lib" { + t.Errorf("Name = %q, want %q", info.Name, "my-python-lib") + } + if info.TestCommand != "pytest" { + t.Errorf("TestCommand = %q, want %q", info.TestCommand, "pytest") + } + if info.Framework != "Python" { + t.Errorf("Framework = %q, want %q", info.Framework, "Python") + } +} + +func TestDetectKeyDirectories(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Create subdirectories + for _, name := range []string{"src", "tests", "docs"} { + if err := os.Mkdir(filepath.Join(dir, name), 0755); err != nil { + t.Fatal(err) + } + } + + found := detectKeyDirectories(dir) + + expected := map[string]bool{ + "src/": false, + "tests/": false, + "docs/": false, + } + for _, d := range found { + if _, ok := expected[d]; ok { + expected[d] = true + } + } + for d, seen := range expected { + if !seen { + t.Errorf("expected directory %q not found in result %v", d, found) + } + } +} + +func TestDetectProject_Fallback(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + info := detectProject(dir) + + // Name should fall back to the directory basename + basename := filepath.Base(dir) + if info.Name != basename { + t.Errorf("Name = %q, want %q (dir basename)", info.Name, basename) + } + if info.Framework != "" { + t.Errorf("Framework = %q, want empty string", info.Framework) + } +} + +func TestRemoveUnfilledPlaceholders(t *testing.T) { + t.Parallel() + + input := "Hello {{FOO}} world {{BAR_BAZ}}" + result := placeholderRe.ReplaceAllString(input, "") + + if strings.Contains(result, "{{FOO}}") { + t.Errorf("result still contains {{FOO}}: %q", result) + } + if strings.Contains(result, "{{BAR_BAZ}}") { + t.Errorf("result still contains {{BAR_BAZ}}: %q", result) + } + expected := "Hello world " + if result != expected { + t.Errorf("result = %q, want %q", result, expected) + } +} + +func TestRemoveEmptySections(t *testing.T) { + t.Parallel() + + input := "## Project Overview\n\n---" + result := removeEmptySections(input) + + if strings.Contains(result, "## Project Overview") { + t.Errorf("empty section was not removed: %q", result) + } +} + +func TestRemoveEmptySections_EmptyCodeFence(t *testing.T) { + t.Parallel() + + input := "## Quick Reference\n\n```bash\n```\n\n---" + result := removeEmptySections(input) + + if strings.Contains(result, "## Quick Reference") { + t.Errorf("section with empty code fence was not removed: %q", result) + } +} + +func TestApplyProjectDetection_EmptyTypecheck(t *testing.T) { + t.Parallel() + + // Template mimics the real CLAUDE-BASE.md layout with table and code fence + baseTemplate := "# {{PROJECT_NAME}}\n\n" + + "| 5. TEST | `{{TEST_COMMAND}}` + `{{TYPECHECK_COMMAND}}` | — | Zero failures |\n\n" + + "## Quick Reference\n\n" + + "```bash\n{{TEST_COMMAND}}\n{{TYPECHECK_COMMAND}}\n```\n\n---\n" + + mockFS := fstest.MapFS{} + + info := ProjectInfo{ + Name: "mygoapp", + Framework: "Go", + TestCommand: "go test ./...", + // TypecheckCommand intentionally empty + } + + result := string(applyProjectDetection([]byte(baseTemplate), info, mockFS)) + + // Table row should NOT contain empty backtick artifact ("` + ` `") + if strings.Contains(result, "+ ` `") { + t.Errorf("result still contains empty backtick artifact: %q", result) + } + // Table should still have test command in backticks + if !strings.Contains(result, "`go test ./...`") { + t.Errorf("test command backticks missing from table") + } + + // Quick Reference code fence should NOT have blank lines + if strings.Contains(result, "```bash\n\n") { + t.Errorf("code fence has leading blank line") + } + if strings.Contains(result, "\n\n```") { + t.Errorf("code fence has trailing blank line") + } + // Test command should be present in the code fence + if !strings.Contains(result, "```bash\ngo test ./...") { + t.Errorf("test command not found in code fence: %q", result) + } +} + +func TestApplyProjectDetection(t *testing.T) { + t.Parallel() + + baseTemplate := `# {{PROJECT_NAME}} - Claude Code Configuration + +## Project Overview + +{{PROJECT_DESCRIPTION}} + +## Key Directories + +{{KEY_DIRECTORIES}} + +--- + +## Quick Reference + +` + "```bash" + ` +{{TEST_COMMAND}} +{{TYPECHECK_COMMAND}} +` + "```" + ` + +--- + + +` + + goLangTemplate := `## Go Rules + +- Follow gofmt conventions +- Use table-driven tests +` + + mockFS := fstest.MapFS{ + "templates/languages/go.md": &fstest.MapFile{ + Data: []byte(goLangTemplate), + }, + } + + info := ProjectInfo{ + Name: "myapp", + Description: "A great application", + Framework: "Go", + LanguageTemplate: "go.md", + TestCommand: "go test ./...", + TypecheckCommand: "go vet ./...", + BuildCommand: "go build ./...", + KeyDirectories: []string{"src/", "tests/"}, + } + + result := string(applyProjectDetection([]byte(baseTemplate), info, mockFS)) + + // All named placeholders should be replaced + if strings.Contains(result, "{{") { + t.Errorf("result still contains unfilled placeholders: %q", result) + } + + // Project name should appear in the title + if !strings.Contains(result, "# myapp - Claude Code Configuration") { + t.Errorf("project name not replaced in title") + } + + // Description should be present + if !strings.Contains(result, "A great application") { + t.Errorf("description not replaced") + } + + // Key directories should be formatted + if !strings.Contains(result, "- `src/`") { + t.Errorf("key directory src/ not found") + } + if !strings.Contains(result, "- `tests/`") { + t.Errorf("key directory tests/ not found") + } + + // Test command should appear + if !strings.Contains(result, "go test ./...") { + t.Errorf("test command not found in result") + } + + // Language template should be inserted + if !strings.Contains(result, "## Go Rules") { + t.Errorf("language template not inserted") + } + if !strings.Contains(result, "table-driven tests") { + t.Errorf("language template content not found") + } + + // LANGUAGE_SPECIFIC marker should be gone + if strings.Contains(result, "") { + t.Errorf("LANGUAGE_SPECIFIC marker still present") + } +} diff --git a/main.go b/main.go index 8982c1c..acf6d06 100644 --- a/main.go +++ b/main.go @@ -330,6 +330,18 @@ func runFullInstall(reader *bufio.Reader, inst *installer.Installer, target Targ func generateConfigFile(_ *installer.Installer, target Target) error { configPath := filepath.Join(".", target.ConfigPath) + // Detect project type (safe in dry-run: only reads the filesystem) + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("cannot determine working directory: %w", err) + } + info := detectProject(cwd) + if info.Framework != "" { + fmt.Printf("Detected: %s (%s)\n", info.Framework, info.Name) + } else { + fmt.Printf("Detected: unknown project type (using defaults for %s)\n", info.Name) + } + if dryRun { if fileExists(configPath) { fmt.Printf("WOULD UPDATE: %s\n", configPath) @@ -350,14 +362,14 @@ func generateConfigFile(_ *installer.Installer, target Target) error { return fmt.Errorf("reading template: %w", err) } - // For Claude Code, install the full CLAUDE-BASE.md template - // For other frameworks, generate a simplified config + // For Claude Code, apply project detection directly + // For other frameworks, generate a framework-specific config var configContent []byte switch target.Name { case "Claude Code": - configContent = baseContent + configContent = applyProjectDetection(baseContent, info, content) default: - configContent = generateFrameworkConfig(target, baseContent) + configContent = generateFrameworkConfig(target, baseContent, info) } if fileExists(configPath) { @@ -374,22 +386,16 @@ func generateConfigFile(_ *installer.Installer, target Target) error { return nil } -func generateFrameworkConfig(target Target, baseContent []byte) []byte { +func generateFrameworkConfig(target Target, baseContent []byte, info ProjectInfo) []byte { header := fmt.Sprintf("# %s - AI Agent Configuration\n\n", target.Name) header += fmt.Sprintf("Skills are installed in `%s/`\n", target.SkillsPath) if target.AgentsPath != "" { header += fmt.Sprintf("Agents are installed in `%s/`\n", target.AgentsPath) } - config := string(baseContent) - config = strings.ReplaceAll(config, "{{PROJECT_NAME}}", "Project") - config = strings.ReplaceAll(config, "{{PROJECT_DESCRIPTION}}", "") - config = strings.ReplaceAll(config, "{{KEY_DIRECTORIES}}", "") - config = strings.ReplaceAll(config, "{{TEST_COMMAND}}", "npm test") - config = strings.ReplaceAll(config, "{{TYPECHECK_COMMAND}}", "npm run typecheck") - config = strings.ReplaceAll(config, "{{BUILD_COMMAND}}", "npm run build") + processed := applyProjectDetection(baseContent, info, content) - return []byte(header + "\n---\n\n" + config) + return []byte(header + "\n---\n\n" + string(processed)) } func loadConfig() (*config.Config, error) {