diff --git a/CLAUDE.md b/CLAUDE.md index 54436b2..a6d0cd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,18 @@ # 🛑 STOP — Run codemap before ANY task +## Repo Root Requirement (Critical) + +Run codemap from the git repository root. Hooks and context files resolve from the current working directory, so running from a subdirectory can break hook context. + +```bash +cd "$(git rev-parse --show-toplevel)" +``` + +`codemap` expects these at repo root: +- `.git/` +- `.codemap/` +- `.claude/settings.local.json` (project-local hooks) + ```bash codemap . # Project structure codemap --deps # How files connect diff --git a/README.md b/README.md index 0e314e5..6d2be50 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,66 @@ scoop install codemap > Other options: [Releases](https://github.com/JordanCoin/codemap/releases) | `go install` | Build from source -## Quick Start +## Recommended Setup (Hooks + Daemon + Config) + +No repo clone is required for normal users. +Run setup from your git repo root (not a subdirectory), or hooks may not resolve project context. + +```bash +# install codemap first (package manager) +brew tap JordanCoin/tap && brew install codemap + +# then run setup inside your project +cd /path/to/your/project +codemap setup +``` + +`codemap setup` is the default onboarding path and configures the pieces that make codemap most useful with Claude: +- creates `.codemap/config.json` (if missing) with auto-detected language filters +- installs codemap hooks into `.claude/settings.local.json` (project-local by default) +- hooks automatically start/read daemon state on session start + +Use global Claude settings instead of project-local settings: + +```bash +codemap setup --global +``` + +Windows equivalent: + +```bash +scoop bucket add codemap https://github.com/JordanCoin/scoop-codemap +scoop install codemap +cd C:\path\to\your\project +codemap setup +``` + +Optional helper scripts (mainly for contributors running from this repo): +- macOS/Linux: `./scripts/onboard.sh /path/to/your/project` +- Windows (PowerShell): `./scripts/onboard.ps1 -ProjectRoot C:\path\to\your\project` + +## Verify Setup + +1. Restart Claude Code or open a new session. +2. At session start, you should see codemap project context. +3. Edit a file and confirm pre/post edit hook context appears. + +## Daily Commands + +```bash +codemap . # Fast tree/context view (respects .codemap/config.json) +codemap --diff # What changed vs main +codemap handoff . # Save layered handoff for cross-agent continuation +codemap --deps . # Dependency flow (requires ast-grep) +``` + +## Other Commands ```bash -codemap . # Project tree -codemap --only swift . # Just Swift files -codemap --exclude .xcassets,Fonts,.png . # Hide assets -codemap --depth 2 . # Limit depth -codemap --diff # What changed vs main -codemap --deps . # Dependency flow -codemap config init # Create .codemap/config.json -codemap handoff . # Save cross-agent handoff summary -codemap github.com/user/repo # Remote GitHub repo +codemap --only swift . +codemap --exclude .xcassets,Fonts,.png . +codemap --depth 2 . +codemap github.com/user/repo ``` ## Options diff --git a/cmd/config.go b/cmd/config.go index b51efc6..ac1abc3 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -12,6 +13,15 @@ import ( "codemap/scanner" ) +var errConfigExists = errors.New("config already exists") + +type configInitResult struct { + Path string + TopExts []string + TotalFiles int + MatchedFiles int +} + // nonCodeExtensions are extensions excluded from "config init" auto-detection. // These are documentation, data, or lock files that rarely represent the // project's primary code. @@ -46,106 +56,107 @@ func RunConfig(subCmd, root string) { } func configInit(root string) { - cfgPath := config.ConfigPath(root) - - // Warn if config already exists - if _, err := os.Stat(cfgPath); err == nil { + result, err := initProjectConfig(root) + if errors.Is(err, errConfigExists) { + cfgPath := config.ConfigPath(root) fmt.Fprintf(os.Stderr, "Config already exists: %s\n", cfgPath) fmt.Fprintln(os.Stderr, "Use 'codemap config show' to view it, or edit directly.") os.Exit(1) } + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating config: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Created %s\n", result.Path) + fmt.Println() + if len(result.TopExts) == 0 { + fmt.Println("No code extensions detected — wrote empty config.") + } else { + fmt.Printf(" only: %s\n", strings.Join(result.TopExts, ", ")) + if result.TotalFiles > 0 { + fmt.Printf(" (%d of %d files)\n", result.MatchedFiles, result.TotalFiles) + } + } + fmt.Println() + fmt.Println("Edit the file to add 'exclude' patterns or adjust 'depth'.") +} + +func initProjectConfig(root string) (configInitResult, error) { + cfgPath := config.ConfigPath(root) + result := configInitResult{Path: cfgPath} + + if _, err := os.Stat(cfgPath); err == nil { + return result, errConfigExists + } else if err != nil && !os.IsNotExist(err) { + return result, err + } - // Scan the repo to find top extensions gitCache := scanner.NewGitIgnoreCache(root) files, err := scanner.ScanFiles(root, gitCache, nil, nil) if err != nil { - fmt.Fprintf(os.Stderr, "Error scanning files: %v\n", err) - os.Exit(1) + return result, fmt.Errorf("scan files: %w", err) } - // Count extensions extCount := make(map[string]int) for _, f := range files { - ext := strings.TrimPrefix(f.Ext, ".") - if ext == "" { - continue - } - ext = strings.ToLower(ext) - if nonCodeExtensions[ext] { + ext := strings.TrimPrefix(strings.ToLower(f.Ext), ".") + if ext == "" || nonCodeExtensions[ext] { continue } extCount[ext]++ } - // Sort by frequency type extEntry struct { Ext string Count int } var entries []extEntry for ext, count := range extCount { - entries = append(entries, extEntry{ext, count}) + entries = append(entries, extEntry{Ext: ext, Count: count}) } sort.Slice(entries, func(i, j int) bool { return entries[i].Count > entries[j].Count }) - // Take top 5 - var topExts []string for i, e := range entries { if i >= 5 { break } - topExts = append(topExts, e.Ext) + result.TopExts = append(result.TopExts, e.Ext) } - if len(topExts) == 0 { - fmt.Println("No code extensions detected — writing empty config.") - topExts = nil - } + cfg := config.ProjectConfig{Only: result.TopExts} - cfg := config.ProjectConfig{ - Only: topExts, - } - - // Ensure .codemap/ directory exists if err := os.MkdirAll(filepath.Dir(cfgPath), 0755); err != nil { - fmt.Fprintf(os.Stderr, "Error creating directory: %v\n", err) - os.Exit(1) + return result, fmt.Errorf("create .codemap directory: %w", err) } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { - fmt.Fprintf(os.Stderr, "Error encoding config: %v\n", err) - os.Exit(1) + return result, fmt.Errorf("encode config: %w", err) } data = append(data, '\n') if err := os.WriteFile(cfgPath, data, 0644); err != nil { - fmt.Fprintf(os.Stderr, "Error writing config: %v\n", err) - os.Exit(1) + return result, fmt.Errorf("write config: %w", err) } - fmt.Printf("Created %s\n", cfgPath) - fmt.Println() - fmt.Printf(" only: %s\n", strings.Join(topExts, ", ")) - if len(files) > 0 && len(topExts) > 0 { - // Count how many files match the selected extensions - matchExts := make(map[string]bool) - for _, ext := range topExts { + result.TotalFiles = len(files) + if len(result.TopExts) > 0 { + matchExts := make(map[string]bool, len(result.TopExts)) + for _, ext := range result.TopExts { matchExts[ext] = true } - matched := 0 for _, f := range files { ext := strings.TrimPrefix(strings.ToLower(f.Ext), ".") if matchExts[ext] { - matched++ + result.MatchedFiles++ } } - fmt.Printf(" (%d of %d files)\n", matched, len(files)) } - fmt.Println() - fmt.Println("Edit the file to add 'exclude' patterns or adjust 'depth'.") + + return result, nil } func configShow(root string) { diff --git a/cmd/setup.go b/cmd/setup.go new file mode 100644 index 0000000..f82686c --- /dev/null +++ b/cmd/setup.go @@ -0,0 +1,253 @@ +package cmd + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "codemap/config" +) + +type claudeHookSpec struct { + Event string + Matcher string + Command string +} + +type claudeHookCommand struct { + Type string `json:"type"` + Command string `json:"command"` +} + +type claudeHookEntry struct { + Matcher string `json:"matcher,omitempty"` + Hooks []claudeHookCommand `json:"hooks"` +} + +type ensureHooksResult struct { + SettingsPath string + CreatedFile bool + WroteFile bool + AddedHooks int + ExistingHooks int + TotalCodemap int + TargetIsGlobal bool +} + +var recommendedClaudeHooks = []claudeHookSpec{ + {Event: "SessionStart", Command: "codemap hook session-start"}, + {Event: "PreToolUse", Matcher: "Edit|Write", Command: "codemap hook pre-edit"}, + {Event: "PostToolUse", Matcher: "Edit|Write", Command: "codemap hook post-edit"}, + {Event: "UserPromptSubmit", Command: "codemap hook prompt-submit"}, + {Event: "PreCompact", Command: "codemap hook pre-compact"}, + {Event: "SessionEnd", Command: "codemap hook session-stop"}, +} + +// RunSetup configures codemap for the recommended hooks-first workflow. +// +// By default it creates: +// - /.codemap/config.json (if missing) +// - /.claude/settings.local.json codemap hook entries +// +// Use --global to target ~/.claude/settings.json for hooks. +func RunSetup(args []string, defaultRoot string) { + fs := flag.NewFlagSet("setup", flag.ContinueOnError) + fs.SetOutput(io.Discard) + useGlobalHooks := fs.Bool("global", false, "Install hooks into ~/.claude/settings.json instead of project-local .claude/settings.local.json") + skipConfig := fs.Bool("no-config", false, "Skip creating .codemap/config.json") + skipHooks := fs.Bool("no-hooks", false, "Skip writing Claude hook settings") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + fmt.Println("Usage: codemap setup [--global] [--no-config] [--no-hooks] [path]") + return + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintln(os.Stderr, "Usage: codemap setup [--global] [--no-config] [--no-hooks] [path]") + os.Exit(2) + } + if fs.NArg() > 1 { + fmt.Fprintln(os.Stderr, "Usage: codemap setup [--global] [--no-config] [--no-hooks] [path]") + os.Exit(1) + } + + root := defaultRoot + if fs.NArg() == 1 { + root = fs.Arg(0) + } + absRoot, err := filepath.Abs(root) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err) + os.Exit(1) + } + + if !*skipConfig { + if _, err := os.Stat(filepath.Join(absRoot, ".git")); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Warning: %s is not a git repository root; continuing setup anyway.\n", absRoot) + } + } + + fmt.Println("codemap setup") + fmt.Printf("Project: %s\n", absRoot) + fmt.Println() + + if *skipConfig { + fmt.Println("Config: skipped (--no-config)") + } else { + cfgResult, err := initProjectConfig(absRoot) + switch { + case errors.Is(err, errConfigExists): + fmt.Printf("Config: already exists (%s)\n", config.ConfigPath(absRoot)) + case err != nil: + fmt.Fprintf(os.Stderr, "Config: failed (%v)\n", err) + os.Exit(1) + default: + if len(cfgResult.TopExts) == 0 { + fmt.Printf("Config: created %s (no code extensions detected)\n", cfgResult.Path) + } else { + fmt.Printf("Config: created %s (only=%s)\n", cfgResult.Path, strings.Join(cfgResult.TopExts, ",")) + } + } + } + + if *skipHooks { + fmt.Println("Hooks: skipped (--no-hooks)") + } else { + hookPath, err := claudeSettingsPath(absRoot, *useGlobalHooks) + if err != nil { + fmt.Fprintf(os.Stderr, "Hooks: failed to resolve settings path (%v)\n", err) + os.Exit(1) + } + hookResult, err := ensureClaudeHooks(hookPath, *useGlobalHooks) + if err != nil { + fmt.Fprintf(os.Stderr, "Hooks: failed (%v)\n", err) + os.Exit(1) + } + switch { + case hookResult.AddedHooks == 0: + fmt.Printf("Hooks: already configured (%s)\n", hookResult.SettingsPath) + case hookResult.CreatedFile: + fmt.Printf("Hooks: created %s (+%d codemap hooks)\n", hookResult.SettingsPath, hookResult.AddedHooks) + default: + fmt.Printf("Hooks: updated %s (+%d codemap hooks)\n", hookResult.SettingsPath, hookResult.AddedHooks) + } + } + + fmt.Println() + fmt.Println("Next:") + fmt.Println(" 1. Restart Claude Code (or open a new session).") + fmt.Println(" 2. Verify hook output appears at session start.") + fmt.Println(" 3. Tune .codemap/config.json if you want narrower context.") +} + +func claudeSettingsPath(projectRoot string, global bool) (string, error) { + if !global { + return filepath.Join(projectRoot, ".claude", "settings.local.json"), nil + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, ".claude", "settings.json"), nil +} + +func ensureClaudeHooks(settingsPath string, global bool) (ensureHooksResult, error) { + result := ensureHooksResult{ + SettingsPath: settingsPath, + TotalCodemap: len(recommendedClaudeHooks), + TargetIsGlobal: global, + } + + settingsExisted := false + root := make(map[string]interface{}) + data, err := os.ReadFile(settingsPath) + switch { + case err == nil: + settingsExisted = true + if len(strings.TrimSpace(string(data))) > 0 { + if err := json.Unmarshal(data, &root); err != nil { + return result, fmt.Errorf("parse %s: %w", settingsPath, err) + } + } + case os.IsNotExist(err): + result.CreatedFile = true + default: + return result, fmt.Errorf("read %s: %w", settingsPath, err) + } + + hooksByEvent := make(map[string][]claudeHookEntry) + if raw, ok := root["hooks"]; ok && raw != nil { + rawJSON, err := json.Marshal(raw) + if err != nil { + return result, fmt.Errorf("encode existing hooks in %s: %w", settingsPath, err) + } + if string(rawJSON) != "null" { + if err := json.Unmarshal(rawJSON, &hooksByEvent); err != nil { + return result, fmt.Errorf("parse hooks in %s: %w", settingsPath, err) + } + } + } + + for _, spec := range recommendedClaudeHooks { + if hasHookSpec(hooksByEvent[spec.Event], spec) { + result.ExistingHooks++ + continue + } + entry := claudeHookEntry{ + Matcher: spec.Matcher, + Hooks: []claudeHookCommand{ + { + Type: "command", + Command: spec.Command, + }, + }, + } + hooksByEvent[spec.Event] = append(hooksByEvent[spec.Event], entry) + result.AddedHooks++ + } + + // Preserve no-op behavior when settings already contain all recommended hooks. + if settingsExisted && result.AddedHooks == 0 { + return result, nil + } + + root["hooks"] = hooksByEvent + + if err := os.MkdirAll(filepath.Dir(settingsPath), 0755); err != nil { + return result, fmt.Errorf("create .claude directory: %w", err) + } + + out, err := json.MarshalIndent(root, "", " ") + if err != nil { + return result, fmt.Errorf("encode settings: %w", err) + } + out = append(out, '\n') + + if err := os.WriteFile(settingsPath, out, 0644); err != nil { + return result, fmt.Errorf("write %s: %w", settingsPath, err) + } + result.WroteFile = true + + return result, nil +} + +func hasHookSpec(entries []claudeHookEntry, spec claudeHookSpec) bool { + targetCommand := strings.TrimSpace(spec.Command) + requiredMatcher := strings.TrimSpace(spec.Matcher) + for _, entry := range entries { + if requiredMatcher != "" && !strings.EqualFold(strings.TrimSpace(entry.Matcher), requiredMatcher) { + continue + } + for _, hook := range entry.Hooks { + if strings.EqualFold(strings.TrimSpace(hook.Type), "command") && strings.TrimSpace(hook.Command) == targetCommand { + return true + } + } + } + return false +} diff --git a/cmd/setup_test.go b/cmd/setup_test.go new file mode 100644 index 0000000..9b0e5f3 --- /dev/null +++ b/cmd/setup_test.go @@ -0,0 +1,205 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureClaudeHooksCreatesSettings(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), ".claude", "settings.local.json") + + result, err := ensureClaudeHooks(settingsPath, false) + if err != nil { + t.Fatalf("ensureClaudeHooks returned error: %v", err) + } + if !result.CreatedFile { + t.Fatal("expected CreatedFile to be true for first write") + } + if !result.WroteFile { + t.Fatal("expected WroteFile to be true for first write") + } + if result.AddedHooks != len(recommendedClaudeHooks) { + t.Fatalf("expected %d added hooks, got %d", len(recommendedClaudeHooks), result.AddedHooks) + } + + settings := readSettingsFile(t, settingsPath) + hooks := readHooksMap(t, settings) + for _, spec := range recommendedClaudeHooks { + if !hasHookSpec(hooks[spec.Event], spec) { + t.Fatalf("expected %q hook command in event %q", spec.Command, spec.Event) + } + } +} + +func TestEnsureClaudeHooksPreservesFieldsAndAvoidsDuplicates(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), ".claude", "settings.local.json") + if err := os.MkdirAll(filepath.Dir(settingsPath), 0755); err != nil { + t.Fatal(err) + } + + existing := `{ + "theme": "dark", + "hooks": { + "SessionStart": [ + { + "hooks": [ + {"type": "command", "command": "codemap hook session-start"} + ] + } + ] + } +}` + if err := os.WriteFile(settingsPath, []byte(existing), 0644); err != nil { + t.Fatal(err) + } + + result, err := ensureClaudeHooks(settingsPath, false) + if err != nil { + t.Fatalf("ensureClaudeHooks returned error: %v", err) + } + if result.CreatedFile { + t.Fatal("expected CreatedFile to be false for existing file") + } + if !result.WroteFile { + t.Fatal("expected WroteFile to be true when new hooks were added") + } + if result.ExistingHooks != 1 { + t.Fatalf("expected ExistingHooks=1, got %d", result.ExistingHooks) + } + if result.AddedHooks != len(recommendedClaudeHooks)-1 { + t.Fatalf("expected %d added hooks, got %d", len(recommendedClaudeHooks)-1, result.AddedHooks) + } + + second, err := ensureClaudeHooks(settingsPath, false) + if err != nil { + t.Fatalf("second ensureClaudeHooks returned error: %v", err) + } + if second.AddedHooks != 0 { + t.Fatalf("expected second run to add 0 hooks, got %d", second.AddedHooks) + } + if second.ExistingHooks != len(recommendedClaudeHooks) { + t.Fatalf("expected second run ExistingHooks=%d, got %d", len(recommendedClaudeHooks), second.ExistingHooks) + } + if second.WroteFile { + t.Fatal("expected second run to avoid rewriting settings file") + } + + settings := readSettingsFile(t, settingsPath) + if got, ok := settings["theme"].(string); !ok || got != "dark" { + t.Fatalf("expected top-level theme field to be preserved, got %#v", settings["theme"]) + } + + hooks := readHooksMap(t, settings) + if len(hooks["SessionStart"]) == 0 { + t.Fatal("expected SessionStart hooks to exist") + } + count := 0 + for _, entry := range hooks["SessionStart"] { + for _, hook := range entry.Hooks { + if hook.Command == "codemap hook session-start" { + count++ + } + } + } + if count != 1 { + t.Fatalf("expected exactly one session-start codemap hook, got %d", count) + } +} + +func TestEnsureClaudeHooksRejectsInvalidJSON(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), ".claude", "settings.local.json") + if err := os.MkdirAll(filepath.Dir(settingsPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(settingsPath, []byte(`{"hooks":`), 0644); err != nil { + t.Fatal(err) + } + + if _, err := ensureClaudeHooks(settingsPath, false); err == nil { + t.Fatal("expected error for malformed settings JSON") + } +} + +func TestEnsureClaudeHooksAddsMatcherScopedHookWhenMatcherMissing(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), ".claude", "settings.local.json") + if err := os.MkdirAll(filepath.Dir(settingsPath), 0755); err != nil { + t.Fatal(err) + } + + existing := `{ + "hooks": { + "SessionStart": [{"hooks":[{"type":"command","command":"codemap hook session-start"}]}], + "PreToolUse": [{"hooks":[{"type":"command","command":"codemap hook pre-edit"}]}], + "PostToolUse": [{"matcher":"Edit|Write","hooks":[{"type":"command","command":"codemap hook post-edit"}]}], + "UserPromptSubmit": [{"hooks":[{"type":"command","command":"codemap hook prompt-submit"}]}], + "PreCompact": [{"hooks":[{"type":"command","command":"codemap hook pre-compact"}]}], + "SessionEnd": [{"hooks":[{"type":"command","command":"codemap hook session-stop"}]}] + } +}` + if err := os.WriteFile(settingsPath, []byte(existing), 0644); err != nil { + t.Fatal(err) + } + + result, err := ensureClaudeHooks(settingsPath, false) + if err != nil { + t.Fatalf("ensureClaudeHooks returned error: %v", err) + } + if result.AddedHooks != 1 { + t.Fatalf("expected exactly 1 added hook for missing matcher, got %d", result.AddedHooks) + } + if result.ExistingHooks != len(recommendedClaudeHooks)-1 { + t.Fatalf("expected ExistingHooks=%d, got %d", len(recommendedClaudeHooks)-1, result.ExistingHooks) + } + + settings := readSettingsFile(t, settingsPath) + hooks := readHooksMap(t, settings) + preToolUseEntries := hooks["PreToolUse"] + + foundRecommended := false + for _, entry := range preToolUseEntries { + if strings.TrimSpace(entry.Matcher) != "Edit|Write" { + continue + } + for _, hook := range entry.Hooks { + if strings.TrimSpace(hook.Command) == "codemap hook pre-edit" && strings.EqualFold(strings.TrimSpace(hook.Type), "command") { + foundRecommended = true + } + } + } + if !foundRecommended { + t.Fatal("expected setup to add matcher-scoped PreToolUse codemap hook") + } +} + +func readSettingsFile(t *testing.T, path string) map[string]interface{} { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var out map[string]interface{} + if err := json.Unmarshal(data, &out); err != nil { + t.Fatal(err) + } + return out +} + +func readHooksMap(t *testing.T, settings map[string]interface{}) map[string][]claudeHookEntry { + t.Helper() + hooksValue, ok := settings["hooks"] + if !ok { + t.Fatal("expected hooks key in settings") + } + raw, err := json.Marshal(hooksValue) + if err != nil { + t.Fatal(err) + } + hooks := make(map[string][]claudeHookEntry) + if err := json.Unmarshal(raw, &hooks); err != nil { + t.Fatal(err) + } + return hooks +} diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 466bb6f..543a5ae 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -18,9 +18,32 @@ Turn Claude into a codebase-aware assistant. These hooks give Claude automatic c ## Quick Setup -**Tell Claude:** "Add codemap hooks to my Claude settings" +Recommended (project-local hooks + config): -Add to `.claude/settings.local.json` in your project (or `~/.claude/settings.json` globally): +```bash +# install codemap (no repo clone needed) +brew tap JordanCoin/tap && brew install codemap + +cd /path/to/your/project +codemap setup +``` + +Global Claude settings instead of project-local: + +```bash +codemap setup --global +``` + +This command: +- creates `.codemap/config.json` when missing +- inserts codemap hook commands into Claude settings (without removing existing hooks) +- keeps hook setup idempotent (safe to run more than once) + +Important: run `codemap setup` from the git repo root. Hook commands run relative to the current working directory; starting Claude from a nested folder can prevent codemap from finding `.git` and `.codemap`. + +### Manual Hook JSON (advanced) + +If you want to manage Claude settings manually, add this `hooks` object to `.claude/settings.local.json` (or `~/.claude/settings.json`): ```json { @@ -91,7 +114,13 @@ Add to `.claude/settings.local.json` in your project (or `~/.claude/settings.jso } ``` -Restart Claude Code. You'll immediately see your project structure at session start. +Restart Claude Code. You should immediately see project context at session start. + +If you intentionally run Claude from subdirectories, pass the repo root explicitly: + +```bash +codemap hook session-start "$(git rev-parse --show-toplevel)" +``` --- @@ -284,22 +313,22 @@ With these hooks, Claude: ## Prerequisites ```bash -# macOS -brew install jonesrussell/tap/codemap +# macOS/Linux +brew tap JordanCoin/tap && brew install codemap # Windows -scoop bucket add codemap https://github.com/jonesrussell/scoop-bucket +scoop bucket add codemap https://github.com/JordanCoin/scoop-codemap scoop install codemap # Go -go install github.com/jonesrussell/codemap@latest +go install github.com/JordanCoin/codemap@latest ``` --- ## Verify It Works -1. Add hooks to your Claude settings (copy the JSON above) +1. Run `codemap setup` in your project 2. Restart Claude Code (or start a new session) 3. You should see project structure at the top 4. Ask Claude to edit a core file - watch for hub warnings diff --git a/main.go b/main.go index 482232b..9cb20cf 100644 --- a/main.go +++ b/main.go @@ -76,6 +76,13 @@ func main() { return } + // Handle "setup" subcommand before global flag parsing + if len(os.Args) >= 2 && os.Args[1] == "setup" { + root, _ := os.Getwd() + cmd.RunSetup(os.Args[2:], root) + return + } + // Handle "handoff" subcommand before global flag parsing if len(os.Args) >= 2 && os.Args[1] == "handoff" { runHandoffSubcommand(os.Args[2:]) @@ -147,6 +154,10 @@ func main() { fmt.Println("Project config:") fmt.Println(" codemap config init # Create .codemap/config.json (auto-detects extensions)") fmt.Println(" codemap config show # Show current project config") + fmt.Println() + fmt.Println("Recommended onboarding:") + fmt.Println(" codemap setup # Configure project config + Claude hooks") + fmt.Println(" codemap setup --global # Write hooks to ~/.claude/settings.json") os.Exit(0) } diff --git a/scripts/onboard.ps1 b/scripts/onboard.ps1 new file mode 100644 index 0000000..83f7750 --- /dev/null +++ b/scripts/onboard.ps1 @@ -0,0 +1,31 @@ +param( + [Parameter(Mandatory = $false)] + [string]$ProjectRoot = (Get-Location).Path +) + +$ErrorActionPreference = "Stop" + +if (-not (Test-Path -LiteralPath $ProjectRoot -PathType Container)) { + Write-Error "Project root not found: $ProjectRoot" +} + +$codemap = Get-Command codemap -ErrorAction SilentlyContinue +if (-not $codemap) { + $scoop = Get-Command scoop -ErrorAction SilentlyContinue + if ($scoop) { + Write-Host "Installing codemap via Scoop..." + scoop bucket add codemap https://github.com/JordanCoin/scoop-codemap | Out-Null + scoop install codemap | Out-Null + } else { + $winget = Get-Command winget -ErrorAction SilentlyContinue + if ($winget) { + Write-Host "Installing codemap via Winget..." + winget install --id JordanCoin.codemap --exact --accept-package-agreements --accept-source-agreements | Out-Null + } else { + Write-Error "codemap is not installed and neither Scoop nor Winget is available. Install codemap first: https://github.com/JordanCoin/codemap#install" + } + } +} + +Write-Host "Running codemap setup for: $ProjectRoot" +codemap setup "$ProjectRoot" diff --git a/scripts/onboard.sh b/scripts/onboard.sh new file mode 100755 index 0000000..8e0fcc6 --- /dev/null +++ b/scripts/onboard.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installs codemap (Homebrew) if missing, then runs the recommended setup flow. +# Usage: +# ./scripts/onboard.sh # current directory as project root +# ./scripts/onboard.sh /path/to/repo # explicit project root + +PROJECT_ROOT="${1:-$PWD}" + +if [[ ! -d "$PROJECT_ROOT" ]]; then + echo "Error: project root not found: $PROJECT_ROOT" >&2 + exit 1 +fi + +if ! command -v codemap >/dev/null 2>&1; then + if command -v brew >/dev/null 2>&1; then + echo "Installing codemap via Homebrew..." + brew tap JordanCoin/tap + brew install codemap + else + echo "Error: codemap is not installed and Homebrew is unavailable." >&2 + echo "Install codemap first: https://github.com/JordanCoin/codemap#install" >&2 + exit 1 + fi +fi + +echo "Running codemap setup for: $PROJECT_ROOT" +codemap setup "$PROJECT_ROOT"