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
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ curl -fsSL "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/JordanCoin/codemap/releases/download/v${CODEMAP_V

## Setup

Run from your git repo root — hooks resolve project context from the working directory.
Run setup anywhere inside your git repo. Repo-scoped commands such as
`setup`, `doctor`, `config`, `watch`, `skill`, `context`, `serve`, and
managed hooks resolve the nearest git root automatically, including linked
worktrees with a `.git` file.

```bash
cd /path/to/your/project
Expand Down Expand Up @@ -163,8 +166,37 @@ codemap --version

### Options

Standard linked Git worktrees automatically reuse the primary worktree's
`.codemap/config.json` and project skills. Create the worktree with Git, an IDE,
or any manager that uses standard linked-worktree metadata, then give the agent
its absolute path:

```bash
git worktree add <path> -b <branch> <base>
codemap -C /tmp/feature-worktree context
```

Normal CLI and plugin MCP calls need no `--setup-root`: central config and skills
come from the primary worktree, while handoffs, watcher files, and hook/session
state remain in the linked worktree. Independent clones have no trusted Git
metadata linking them, so sharing setup between them still requires an explicit
override:

```bash
codemap -C /tmp/independent-clone --setup-root /path/to/original context
```

`-C`/`--project-root` selects the repository Codemap operates on.
`--setup-root` explicitly reuses `<repository>/.codemap` policy and runtime state
from another checkout. Both accept a repository or subdirectory; relative setup
paths resolve from the project root.

| Flag | Description |

| Flag | Description |
|------|-------------|
| `-C, --project-root <repo>` | Operate on code in `<repo>` |
| `--setup-root <repo>` | Explicitly reuse policy and runtime state from `<repo>/.codemap` |
| `--depth, -d <n>` | Limit tree depth (0 = unlimited) |
| `--only <exts>` | Only include files with these extensions |
| `--exclude <patterns>` | Exclude files matching patterns |
Expand Down
7 changes: 6 additions & 1 deletion cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ var nonCodeExtensions = map[string]bool{

// RunConfig dispatches the "config" subcommand.
func RunConfig(subCmd, root string) {
absRoot, err := filepath.Abs(root)
absRoot, _, err := ResolveNearestGitRoot(root)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
absRoot, err = ValidateProjectPath(absRoot)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
Expand Down
11 changes: 10 additions & 1 deletion cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,20 @@ func RunContext(args []string, root string) {
os.Exit(1)
}

if fs.NArg() > 0 {
explicitRoot := fs.NArg() > 0
if explicitRoot {
root = fs.Arg(0)
}

absRoot, err := filepath.Abs(root)
if !explicitRoot {
absRoot, _, err = ResolveNearestGitRoot(root)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
absRoot, err = ValidateProjectPath(absRoot)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
Expand Down
16 changes: 16 additions & 0 deletions cmd/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ import (
"codemap/watch"
)

func TestRunContextPreservesExplicitSubtree(t *testing.T) {
root := t.TempDir()
nested := filepath.Join(root, "pkg")
mustWriteFile(t, filepath.Join(root, ".git", "HEAD"), "ref: refs/heads/main\n")
mustWriteFile(t, filepath.Join(nested, "main.go"), "package pkg\n")

var envelope ContextEnvelope
out := captureOutput(func() { RunContext([]string{nested}, root) })
if err := json.Unmarshal([]byte(out), &envelope); err != nil {
t.Fatal(err)
}
if envelope.Project.Root != nested {
t.Fatalf("context root = %q, want explicit subtree %q", envelope.Project.Root, nested)
}
}

func TestDetectLanguagesFromFiles_ManifestSignals(t *testing.T) {
root := t.TempDir()

Expand Down
50 changes: 44 additions & 6 deletions cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"strings"
"time"

"codemap/config"
"codemap/internal/buildinfo"

"github.com/modelcontextprotocol/go-sdk/mcp"
Expand Down Expand Up @@ -71,11 +72,21 @@ func RunDoctor(args []string, defaultRoot string) int {
if fs.NArg() == 1 {
root = fs.Arg(0)
}
root, err := filepath.Abs(root)
root, _, err := ResolveNearestGitRoot(root)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err)
return 1
}
root, err = ValidateProjectPath(root)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err)
return 1
}
// Canonicalize so doctor paths, config, and hooks agree (e.g. macOS
// /tmp -> /private/tmp, /var -> /private/var).
if canonical, err := filepath.EvalSymlinks(root); err == nil {
root = canonical
}

failures := 0
checkExecutable := func(label, name string, required bool) (bool, string) {
Expand All @@ -100,7 +111,7 @@ func RunDoctor(args []string, defaultRoot string) int {
fmt.Printf("OK %s: %s\n", label, path)
}

checkFile("project config", filepath.Join(root, ".codemap", "config.json"), validateJSONFile)
checkFile("project config", config.ConfigPath(root), validateJSONFile)
claudeSettings, claudeSettingsErr := claudeSettingsPath(root, *global)
claudeMCP, claudeMCPErr := claudeMCPPath(root, *global)
codexHooks, codexHooksErr := codexHooksPath(root, *global)
Expand Down Expand Up @@ -481,7 +492,19 @@ func splitHookCommand(command string) (executable, args string, ok bool) {
func hookExecutableIsCodemap(path string) bool {
normalized := strings.ReplaceAll(path, `\`, "/")
name := strings.ToLower(normalized[strings.LastIndex(normalized, "/")+1:])
return name == "codemap" || name == "codemap.exe"
if name == "codemap" || name == "codemap.exe" {
return true
}
// The running binary is codemap even when it is a test binary or a
// renamed deploy artifact; accept the exact executable that wrote the hook.
if running, err := os.Executable(); err == nil {
if abs, err := filepath.Abs(path); err == nil {
if runningAbs, err := filepath.Abs(running); err == nil && abs == runningAbs {
return true
}
}
}
return false
}

func validateClaudeMCP(path string) error {
Expand Down Expand Up @@ -523,6 +546,17 @@ func validateClaudeLocalMCP(projectRoot string) func(string) error {
return fmt.Errorf("no project-scoped MCP servers registered")
}
project, ok := projects[projectRoot].(map[string]any)
if !ok {
// The stored key may use the logical or canonical spelling of the
// same directory; match the canonical form either way.
for key, value := range projects {
keyCanonical, err := filepath.EvalSymlinks(key)
if err == nil && keyCanonical == projectRoot {
project, ok = value.(map[string]any)
break
}
}
}
if !ok {
return fmt.Errorf("no MCP servers registered for %s", projectRoot)
}
Expand Down Expand Up @@ -605,14 +639,18 @@ func parseDoctorManagedLaunch(raw any, agent string) (doctorManagedLaunch, error
return doctorManagedLaunch{}, fmt.Errorf("codemap MCP command must be a non-empty string")
}
args := mcpServerArgs(server)
launch := doctorManagedLaunch{command: command, args: args, integration: doctorManagedIntegration(args)}
_, managedArgs, err := ParseGlobalRootOptions(args)
if err != nil {
return doctorManagedLaunch{}, fmt.Errorf("invalid codemap root arguments: %w", err)
}
launch := doctorManagedLaunch{command: command, args: args, integration: doctorManagedIntegration(managedArgs)}
if command == "codemap" && stringSlicesEqual(args, []string{"mcp"}) {
return launch, fmt.Errorf("legacy PATH-relative codemap MCP definition is stale")
}
if len(args) != 5 || args[0] != "mcp" || args[1] != "--configured-version" || args[2] == "" || args[3] != "--integration" {
if len(managedArgs) != 5 || managedArgs[0] != "mcp" || managedArgs[1] != "--configured-version" || managedArgs[2] == "" || managedArgs[3] != "--integration" {
return launch, fmt.Errorf("unrecognized codemap MCP arguments")
}
launch.configuredVersion = args[2]
launch.configuredVersion = managedArgs[2]
if !filepath.IsAbs(command) {
return launch, fmt.Errorf("codemap MCP command is not absolute: %q", command)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/doctor_scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ func TestRunDoctorFindsLocalScopedClaudeMCP(t *testing.T) {
}
server := map[string]any{
"command": binary,
"args": managedMCPArgs("1.0.0", "claude-setup"),
"args": managedMCPArgs(root, "1.0.0", "claude-setup"),
}
payload := map[string]any{
"mcpServers": map[string]any{},
Expand Down
59 changes: 59 additions & 0 deletions cmd/doctor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package cmd

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestRunDoctorUsesNearestGitRootFromNestedDirectory(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}

setupCode := RunSetup([]string{"--agent", "claude"}, root)
if setupCode != 0 {
t.Fatalf("RunSetup() exit code = %d, want 0", setupCode)
}

prevLookPath := doctorLookPath
prevVersionProbe := doctorVersionProbe
prevMCPProbe := doctorMCPProbe
doctorLookPath = func(name string) (string, error) {
return filepath.Join("/tmp", name), nil
}
doctorVersionProbe = func(launch doctorManagedLaunch) error { return nil }
doctorMCPProbe = func(launch doctorManagedLaunch) error { return nil }
t.Cleanup(func() {
doctorLookPath = prevLookPath
doctorVersionProbe = prevVersionProbe
doctorMCPProbe = prevMCPProbe
})

nested := filepath.Join(root, "pkg", "feature")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}

var code int
out := captureOutput(func() {
code = RunDoctor([]string{"--agent", "claude"}, nested)
})
if code != 0 {
t.Fatalf("RunDoctor() exit code = %d, want 0 with output:\n%s", code, out)
}

rootConfig := filepath.Join(root, ".codemap", "config.json")
nestedConfig := filepath.Join(nested, ".codemap", "config.json")
if !strings.Contains(out, rootConfig) {
t.Fatalf("expected doctor output to validate root config %q, got:\n%s", rootConfig, out)
}
if strings.Contains(out, nestedConfig) {
t.Fatalf("expected doctor output to avoid nested config path %q, got:\n%s", nestedConfig, out)
}
}
30 changes: 21 additions & 9 deletions cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (

"codemap/config"
"codemap/handoff"
"codemap/internal/projectpath"
"codemap/limits"
"codemap/scanner"
"codemap/skills"
Expand Down Expand Up @@ -235,6 +236,15 @@ func waitForDaemonState(root string, timeout time.Duration) *watch.State {

// RunHook executes the named hook with the given project root
func RunHook(hookName, root string) error {
resolvedRoot, _, err := ResolveNearestGitRoot(root)
if err != nil {
return err
}
root, err = ValidateProjectPath(resolvedRoot)
if err != nil {
return err
}

var fn func() error
switch hookName {
case "session-start":
Expand Down Expand Up @@ -348,7 +358,7 @@ func hookSessionStart(root string) error {
depth = projCfg.Depth
}

args := []string{"--depth", strconv.Itoa(depth)}
args := projectpath.PrependSetupRootArgs("--depth", strconv.Itoa(depth))
if len(projCfg.Only) > 0 {
args = append(args, "--only", strings.Join(projCfg.Only, ","))
}
Expand Down Expand Up @@ -451,7 +461,7 @@ func showDiffVsMain(root string, fileCount int, fileCountKnown bool, projCfg con

// Run codemap --diff to show richer impact analysis on manageable repos.
diffBudget := projCfg.DiffOutputBytes()
args := []string{"--diff"}
args := projectpath.PrependSetupRootArgs("--diff")
if len(projCfg.Only) > 0 {
args = append(args, "--only", strings.Join(projCfg.Only, ","))
}
Expand Down Expand Up @@ -529,7 +539,7 @@ func showLightweightDiffVsMain(root string) {

// getLastSessionEvents reads events.log for previous session context
func getLastSessionEvents(root string) []string {
eventsFile := filepath.Join(root, ".codemap", "events.log")
eventsFile := filepath.Join(projectpath.RuntimeCodemapDir(root), "events.log")
f, err := os.Open(eventsFile)
if err != nil {
return nil
Expand Down Expand Up @@ -696,7 +706,8 @@ func startDaemon(root string) {
if err != nil {
return
}
cmd := hookExecCommand(exe, "watch", "start", root)
args := projectpath.PrependSetupRootArgs("watch", "start", root)
cmd := hookExecCommand(exe, args...)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
Expand Down Expand Up @@ -876,7 +887,7 @@ func hookPromptSubmit(root string) error {

// writeStatuslineState writes a tiny file for the statusline to read.
func writeStatuslineState(root string, intent TaskIntent) {
codemapDir := filepath.Join(root, ".codemap")
codemapDir := projectpath.RuntimeCodemapDir(root)
status := intent.Category
if intent.RiskLevel != "low" {
status += " " + intent.RiskLevel
Expand Down Expand Up @@ -1368,7 +1379,7 @@ func showSessionProgress(root, sessionID string) {

// hookPreCompact saves hub state before context compaction
func hookPreCompact(root string) error {
codemapDir := filepath.Join(root, ".codemap")
codemapDir := projectpath.RuntimeCodemapDir(root)
if err := os.MkdirAll(codemapDir, 0755); err != nil {
return err
}
Expand Down Expand Up @@ -1683,7 +1694,7 @@ func updateSessionLease(root, sessionID string, active bool, now time.Time, acti
}
return nil
}
codemapDir := filepath.Join(root, ".codemap")
codemapDir := projectpath.RuntimeCodemapDir(root)
if err := os.MkdirAll(codemapDir, 0o755); err != nil {
return err
}
Expand Down Expand Up @@ -1775,7 +1786,8 @@ func stopDaemon(root string) {
if err != nil {
return
}
cmd := hookExecCommand(exe, "watch", "stop", root)
args := projectpath.PrependSetupRootArgs("watch", "stop", root)
cmd := hookExecCommand(exe, args...)
cmd.Run()
}

Expand Down Expand Up @@ -2065,7 +2077,7 @@ func hookSessionStartMultiRepo(root string, childRepos []string) error {
depth = projCfg.Depth
}

args := []string{"--depth", strconv.Itoa(depth)}
args := projectpath.PrependSetupRootArgs("--depth", strconv.Itoa(depth))
if len(projCfg.Only) > 0 {
args = append(args, "--only", strings.Join(projCfg.Only, ","))
}
Expand Down
Loading
Loading