Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 50 additions & 269 deletions README.md

Large diffs are not rendered by default.

File renamed without changes.
67 changes: 67 additions & 0 deletions docs/MCP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Codemap MCP Server

Run codemap as an MCP (Model Context Protocol) server for deep Claude integration.

## Setup

### Build

```bash
make build-mcp
```

### Claude Code

```bash
claude mcp add --transport stdio codemap -- /path/to/codemap-mcp
```

Or add to your project's `.mcp.json`:

```json
{
"mcpServers": {
"codemap": {
"command": "/path/to/codemap-mcp",
"args": []
}
}
}
```

### Claude Desktop

> Claude Desktop cannot see your local files by default. This MCP server runs on your machine and gives Claude that ability.

Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
"mcpServers": {
"codemap": {
"command": "/path/to/codemap-mcp"
}
}
}
```

## Available Tools

| Tool | Description |
|------|-------------|
| `status` | Verify MCP connection and local filesystem access |
| `list_projects` | Discover projects in a parent directory (with optional filter) |
| `get_structure` | Project tree view with file sizes and language detection |
| `get_dependencies` | Dependency flow with imports, functions, and hub files |
| `get_diff` | Changed files with line counts and impact analysis |
| `find_file` | Find files by name pattern |
| `get_importers` | Find all files that import a specific file |

## Usage

Once configured, Claude can use these tools automatically. Try asking:

- "What's the structure of this project?"
- "Show me the dependency flow"
- "What files import utils.go?"
- "What changed since the last commit?"
27 changes: 26 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ func main() {
diffMode := flag.Bool("diff", false, "Only show files changed vs main (or use --ref to specify branch)")
diffRef := flag.String("ref", "main", "Branch/ref to compare against (use with --diff)")
depthLimit := flag.Int("depth", 0, "Limit tree depth (0 = unlimited)")
onlyExts := flag.String("only", "", "Only show files with these extensions (comma-separated, e.g., 'swift,go')")
excludePatterns := flag.String("exclude", "", "Exclude files matching patterns (comma-separated, e.g., '.xcassets,Fonts')")
jsonMode := flag.Bool("json", false, "Output JSON (for Python renderer compatibility)")
debugMode := flag.Bool("debug", false, "Show debug info (gitignore loading, paths, etc.)")
watchMode := flag.Bool("watch", false, "Live file watcher daemon (experimental)")
Expand All @@ -79,6 +81,8 @@ func main() {
fmt.Println(" --diff Only show files changed vs main")
fmt.Println(" --ref <branch> Branch to compare against (default: main)")
fmt.Println(" --depth, -d <n> Limit tree depth (0 = unlimited)")
fmt.Println(" --only <exts> Only show files with these extensions (e.g., 'swift,go')")
fmt.Println(" --exclude <patterns> Exclude paths matching patterns (e.g., '.xcassets,Fonts')")
fmt.Println(" --importers <file> Check file impact (who imports it, hub status)")
fmt.Println()
fmt.Println("Examples:")
Expand All @@ -89,6 +93,8 @@ func main() {
fmt.Println(" codemap --diff # Files changed vs main")
fmt.Println(" codemap --diff --ref develop # Files changed vs develop")
fmt.Println(" codemap --depth 3 . # Show only 3 levels deep")
fmt.Println(" codemap --only swift . # Just Swift files")
fmt.Println(" codemap --exclude .xcassets,Fonts,.png # Hide assets")
fmt.Println(" codemap --importers scanner/types.go # Check file impact")
fmt.Println()
fmt.Println("Hooks (for Claude Code integration):")
Expand All @@ -115,6 +121,23 @@ func main() {
// Initialize gitignore cache (supports nested .gitignore files)
gitCache := scanner.NewGitIgnoreCache(root)

// Parse --only and --exclude flags
var only, exclude []string
if *onlyExts != "" {
for _, ext := range strings.Split(*onlyExts, ",") {
if trimmed := strings.TrimSpace(ext); trimmed != "" {
only = append(only, trimmed)
}
}
}
if *excludePatterns != "" {
for _, pattern := range strings.Split(*excludePatterns, ",") {
if trimmed := strings.TrimSpace(pattern); trimmed != "" {
exclude = append(exclude, trimmed)
}
}
}

if *debugMode {
fmt.Fprintf(os.Stderr, "[debug] Root path: %s\n", root)
fmt.Fprintf(os.Stderr, "[debug] Absolute path: %s\n", absRoot)
Expand Down Expand Up @@ -165,7 +188,7 @@ func main() {
}

// Scan files
files, err := scanner.ScanFiles(root, gitCache)
files, err := scanner.ScanFiles(root, gitCache, only, exclude)
if err != nil {
fmt.Fprintf(os.Stderr, "Error walking tree: %v\n", err)
os.Exit(1)
Expand All @@ -188,6 +211,8 @@ func main() {
DiffRef: activeDiffRef,
Impact: impact,
Depth: *depthLimit,
Only: only,
Exclude: exclude,
}

// Render or output JSON
Expand Down
8 changes: 4 additions & 4 deletions mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ func handleGetStructure(ctx context.Context, req *mcp.CallToolRequest, input Pat
}

gitCache := scanner.NewGitIgnoreCache(input.Path)
files, err := scanner.ScanFiles(input.Path, gitCache)
files, err := scanner.ScanFiles(input.Path, gitCache, nil, nil)
if err != nil {
return errorResult("Scan error: " + err.Error()), nil, nil
}
Expand Down Expand Up @@ -257,7 +257,7 @@ func handleGetDiff(ctx context.Context, req *mcp.CallToolRequest, input DiffInpu
}

gitCache := scanner.NewGitIgnoreCache(input.Path)
files, err := scanner.ScanFiles(input.Path, gitCache)
files, err := scanner.ScanFiles(input.Path, gitCache, nil, nil)
if err != nil {
return errorResult("Scan error: " + err.Error()), nil, nil
}
Expand All @@ -282,7 +282,7 @@ func handleGetDiff(ctx context.Context, req *mcp.CallToolRequest, input DiffInpu

func handleFindFile(ctx context.Context, req *mcp.CallToolRequest, input FindInput) (*mcp.CallToolResult, any, error) {
gitCache := scanner.NewGitIgnoreCache(input.Path)
files, err := scanner.ScanFiles(input.Path, gitCache)
files, err := scanner.ScanFiles(input.Path, gitCache, nil, nil)
if err != nil {
return errorResult("Scan error: " + err.Error()), nil, nil
}
Expand Down Expand Up @@ -408,7 +408,7 @@ func handleListProjects(ctx context.Context, req *mcp.CallToolRequest, input Lis
// Uses the same scanner logic as the main codemap command (respects nested .gitignore files)
func getProjectStats(path string) string {
gitCache := scanner.NewGitIgnoreCache(path)
files, err := scanner.ScanFiles(path, gitCache)
files, err := scanner.ScanFiles(path, gitCache, nil, nil)
if err != nil {
return "(error scanning)"
}
Expand Down
2 changes: 1 addition & 1 deletion scanner/filegraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func BuildFileGraph(root string) (*FileGraph, error) {

// Scan all files
gitCache := NewGitIgnoreCache(root)
files, err := ScanFiles(root, gitCache)
files, err := ScanFiles(root, gitCache, nil, nil)
if err != nil {
return nil, err
}
Expand Down
4 changes: 3 additions & 1 deletion scanner/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ type Project struct {
Files []FileInfo `json:"files"`
DiffRef string `json:"diff_ref,omitempty"`
Impact []ImpactInfo `json:"impact,omitempty"`
Depth int `json:"depth,omitempty"` // Max tree depth (0 = unlimited)
Depth int `json:"depth,omitempty"` // Max tree depth (0 = unlimited)
Only []string `json:"only,omitempty"` // Extension filter (e.g., ["swift", "go"])
Exclude []string `json:"exclude,omitempty"` // Exclusion patterns (e.g., [".xcassets", "Fonts"])
}

// FileAnalysis holds extracted info about a single file for deps mode.
Expand Down
87 changes: 85 additions & 2 deletions scanner/walker.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,70 @@ var IgnoredDirs = map[string]bool{
"grammars": true,
}

// matchesPattern does smart pattern matching:
// - ".png" or "png" → extension match (case-insensitive)
// - "Fonts" → directory/component match (contains /Fonts/ or ends with /Fonts)
// - "*test*" → glob pattern (only if contains * or ?)
func matchesPattern(relPath string, pattern string) bool {
// If pattern contains glob characters, use glob matching
if strings.ContainsAny(pattern, "*?") {
// Match against filename
if matched, _ := filepath.Match(pattern, filepath.Base(relPath)); matched {
return true
}
// Match against full relative path
if matched, _ := filepath.Match(pattern, relPath); matched {
return true
}
return false
}

// Extension match: .png, .xcassets, png, xcassets
ext := strings.TrimPrefix(pattern, ".")
if strings.HasSuffix(strings.ToLower(relPath), "."+strings.ToLower(ext)) {
return true
}

// Directory component match: Fonts → matches path/Fonts/file or path/Fonts
if strings.Contains(relPath, "/"+pattern+"/") ||
strings.HasSuffix(relPath, "/"+pattern) ||
strings.HasPrefix(relPath, pattern+"/") ||
relPath == pattern {
return true
}

return false
}

// shouldIncludeFile checks if a file passes the only/exclude filters
func shouldIncludeFile(relPath string, ext string, only []string, exclude []string) bool {
// If --only specified, file extension must be in the list
if len(only) > 0 {
extNoDot := strings.TrimPrefix(ext, ".")
found := false
for _, o := range only {
o = strings.TrimPrefix(strings.TrimSpace(o), ".")
if strings.EqualFold(extNoDot, o) {
found = true
break
}
}
if !found {
return false
}
}

// If --exclude specified, check against each pattern
for _, pattern := range exclude {
pattern = strings.TrimSpace(pattern)
if pattern != "" && matchesPattern(relPath, pattern) {
return false
}
}

return true
}

// LoadGitignore loads .gitignore from root if it exists
// Deprecated: Use NewGitIgnoreCache for nested gitignore support
func LoadGitignore(root string) *ignore.GitIgnore {
Expand All @@ -145,7 +209,9 @@ func LoadGitignore(root string) *ignore.GitIgnore {

// ScanFiles walks the directory tree and returns all files.
// Supports nested .gitignore files via GitIgnoreCache.
func ScanFiles(root string, cache *GitIgnoreCache) ([]FileInfo, error) {
// only: list of extensions to include (empty = all)
// exclude: list of patterns to exclude
func ScanFiles(root string, cache *GitIgnoreCache, only []string, exclude []string) ([]FileInfo, error) {
var files []FileInfo
absRoot, _ := filepath.Abs(root)

Expand Down Expand Up @@ -175,6 +241,16 @@ func ScanFiles(root string, cache *GitIgnoreCache) ([]FileInfo, error) {
return filepath.SkipDir
}
}
// Check if directory matches any exclude pattern
relPath, _ := filepath.Rel(absRoot, absPath)
if relPath != "." {
for _, pattern := range exclude {
pattern = strings.TrimSpace(pattern)
if pattern != "" && matchesPattern(relPath, pattern) {
return filepath.SkipDir
}
}
}
return nil
}

Expand All @@ -184,10 +260,17 @@ func ScanFiles(root string, cache *GitIgnoreCache) ([]FileInfo, error) {
}

relPath, _ := filepath.Rel(absRoot, absPath)
ext := filepath.Ext(path)

// Apply user filters (--only and --exclude)
if !shouldIncludeFile(relPath, ext, only, exclude) {
return nil
}

files = append(files, FileInfo{
Path: relPath,
Size: info.Size(),
Ext: filepath.Ext(path),
Ext: ext,
})

return nil
Expand Down
16 changes: 8 additions & 8 deletions scanner/walker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func TestScanFiles(t *testing.T) {
}

// Scan the directory
result, err := ScanFiles(tmpDir, nil)
result, err := ScanFiles(tmpDir, nil, nil, nil)
if err != nil {
t.Fatalf("ScanFiles failed: %v", err)
}
Expand Down Expand Up @@ -86,7 +86,7 @@ func TestScanFilesIgnoresDirs(t *testing.T) {
t.Fatal(err)
}

result, err := ScanFiles(tmpDir, nil)
result, err := ScanFiles(tmpDir, nil, nil, nil)
if err != nil {
t.Fatalf("ScanFiles failed: %v", err)
}
Expand Down Expand Up @@ -120,7 +120,7 @@ func TestScanFilesExtensions(t *testing.T) {
}
}

result, err := ScanFiles(tmpDir, nil)
result, err := ScanFiles(tmpDir, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -291,7 +291,7 @@ func TestNestedGitignore(t *testing.T) {

// Scan with GitIgnoreCache
cache := NewGitIgnoreCache(tmpDir)
files, err := ScanFiles(tmpDir, cache)
files, err := ScanFiles(tmpDir, cache, nil, nil)
if err != nil {
t.Fatalf("ScanFiles failed: %v", err)
}
Expand Down Expand Up @@ -334,7 +334,7 @@ func TestGitIgnoreCacheNil(t *testing.T) {
}

// Scan without gitignore cache
files, err := ScanFiles(tmpDir, nil)
files, err := ScanFiles(tmpDir, nil, nil, nil)
if err != nil {
t.Fatalf("ScanFiles failed: %v", err)
}
Expand Down Expand Up @@ -432,7 +432,7 @@ func TestNestedGitignoreMonorepo(t *testing.T) {

// Scan with GitIgnoreCache
cache := NewGitIgnoreCache(tmpDir)
files, err := ScanFiles(tmpDir, cache)
files, err := ScanFiles(tmpDir, cache, nil, nil)
if err != nil {
t.Fatalf("ScanFiles failed: %v", err)
}
Expand Down Expand Up @@ -479,7 +479,7 @@ func TestNestedGitignoreUnignore(t *testing.T) {
os.WriteFile(filepath.Join(tmpDir, "sub", "other.log"), []byte("other"), 0644)

cache := NewGitIgnoreCache(tmpDir)
files, err := ScanFiles(tmpDir, cache)
files, err := ScanFiles(tmpDir, cache, nil, nil)
if err != nil {
t.Fatalf("ScanFiles failed: %v", err)
}
Expand Down Expand Up @@ -536,7 +536,7 @@ func TestNestedGitignoreDirectoryIgnore(t *testing.T) {
}

cache := NewGitIgnoreCache(tmpDir)
files, err := ScanFiles(tmpDir, cache)
files, err := ScanFiles(tmpDir, cache, nil, nil)
if err != nil {
t.Fatalf("ScanFiles failed: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion watch/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ func (d *Daemon) WriteInitialState() {
func (d *Daemon) fullScan() error {
start := time.Now()

files, err := scanner.ScanFiles(d.root, d.gitCache)
files, err := scanner.ScanFiles(d.root, d.gitCache, nil, nil)
if err != nil {
return err
}
Expand Down
Loading